First Principles of simpleflakes

May 17, 2026 ยท View on GitHub

simpleflakes is easiest to understand if you ignore the API surface for a moment and start from the problem.

We want an identifier that is:

  • small enough to fit in 64 bits
  • sortable by creation time
  • cheap to generate in hot paths
  • usable across many processes or machines without coordination
  • reversible enough to recover when it was created
  • safe in JavaScript, where number cannot exactly represent every 64-bit integer

Those constraints drive almost every design choice in this library.

The Core Problem

An ID generator for distributed systems usually has to balance four competing goals:

  1. Time order
  2. Uniqueness
  3. Compact size
  4. Operational simplicity

You can optimize any one of these in isolation, but the interesting work starts when you need all four at once.

UUIDs solve uniqueness well, but they are 128 bits and less friendly for integer indexes. Central counters solve ordering and uniqueness, but they require coordination and become a bottleneck. Classic Snowflake-style schemes solve this with timestamp bits plus worker and sequence bits, but they require machine identity and local counter state.

simpleflakes chooses a different tradeoff:

  • keep time ordering
  • stay inside 64 bits
  • remove worker IDs and sequence state
  • replace coordination with randomness

That is the first-principles idea behind the library.

The Inverted Problem

Charlie Munger often described inversion as a better way to think: instead of asking only how to succeed, ask how to fail, then systematically avoid those failure modes.

Applied here, the inverted question is:

"What would an ID scheme look like if we wanted it to be expensive, fragile, hard to sort, and painful to operate in distributed systems?"

The answer would look something like this:

  • use identifiers that are larger than necessary
  • depend on a central allocator or shared mutable state
  • require every node to coordinate before generating IDs
  • use a representation that loses integer precision in JavaScript
  • make creation time impossible or expensive to recover
  • make sort order unrelated to generation time
  • hide important constraints behind implicit runtime behavior

From that inversion, the design goals for simpleflakes become clearer:

  • 64 bits instead of 128 when the use case permits it
  • local generation instead of central coordination
  • bigint instead of unsafe number
  • recoverable packed fields instead of opaque randomness only
  • timestamp-dominant ordering instead of arbitrary numeric order
  • explicit bit-budget tradeoffs instead of pretending constraints do not exist

The library is easier to understand once viewed as the deliberate opposite of a failure-prone distributed ID design.

The Minimal Model

At its core, a simpleflake is just this:

flake = ((timestamp - epoch) << 23) | randomBits

That single expression captures the full model.

  • timestamp - epoch gives a smaller time value than raw Unix time
  • << 23 moves that time value into the upper bits
  • randomBits occupies the lower 23 bits
  • | merges both parts because their bit ranges do not overlap

The implementation in src/simpleflakes.ts does exactly that.

Why BigInt Is Mandatory

JavaScript number uses IEEE 754 double precision, which cannot exactly represent all 64-bit integers. Once an ID exceeds Number.MAX_SAFE_INTEGER, exact integer round-tripping is no longer guaranteed.

simpleflakes therefore uses bigint as the canonical representation. That choice is not cosmetic. It is required if a 64-bit integer ID must remain exact.

From first principles:

  • the identifier is fundamentally a 64-bit integer
  • JavaScript number cannot safely hold every 64-bit integer
  • therefore the correct runtime type is bigint

Why Use a Custom Epoch

Raw Unix timestamps are large. If you stored milliseconds since 1970 directly, you would waste high bits on history you may not care about.

simpleflakes uses 2000-01-01T00:00:00.000Z as its default epoch:

946684800000

Subtracting a custom epoch gives more useful range inside the same bit budget.

This means the timestamp field stores:

elapsedMilliseconds = timestamp - epoch

instead of absolute Unix time.

That is why parsing has to add the epoch back:

timestamp = (flake >> 23) + epoch

Why Put Time in the Upper Bits

If later timestamps should usually compare as larger integers, time must dominate the numeric ordering. The cleanest way to do that is to place the timestamp in the high bits and the per-millisecond differentiator in the low bits.

That gives two useful properties:

  • if ts2 > ts1, then flake2 > flake1 when both use the same epoch and valid low bits
  • IDs naturally sort by time at integer comparison level

This is why the timestamp gets shifted left by 23 bits before the final merge.

Why 41 Bits for Time and 23 Bits for Randomness

The entire ID must fit in 64 bits, so the library has a fixed budget:

64 total bits = 41 timestamp bits + 23 random bits

That split is the heart of the design.

41 timestamp bits

2^41 milliseconds is about 69.7 years.

That means a custom epoch at the start of year 2000 yields roughly this operating window:

  • start: 2000
  • limit: around 2069

The exact implementation stores only the elapsed millisecond count in those 41 bits.

23 random bits

2^23 = 8,388,608 possible values.

That gives each millisecond a space of more than 8.3 million distinct low-bit combinations.

Every extra random bit doubles that space. In the small-burst case, that means each additional bit roughly cuts collision risk in half.

From first principles, this solves the question: "How do we avoid shared machine IDs or counters?"

The answer is: by making same-millisecond uniqueness probabilistic instead of coordinated.

What 2^23 means for collision risk

Those 23 bits are what materially reduce collision probability. They create 8,388,608 random slots for every single millisecond bucket.

If n IDs are generated in the same millisecond, the probability of at least one collision is approximately:

p(collision) โ‰ˆ 1 - exp(-(n * (n - 1)) / (2 * $2^{23}$))

For small n, that is very close to:

n * (n - 1) / (2 * $2^{23}$)

The important qualifier is "in the same millisecond." A million IDs spread across many milliseconds is a very different situation from a burst of IDs landing inside one timestamp bucket.

Here is the intuition in concrete scenarios:

IDs generated in the same millisecondApproximate collision chanceComparison
20.0000119% or 1 in 8,388,608Exactly the same as flipping 23 heads in a row
100.000536% or about 1 in 186,000Between flipping 17 heads in a row (1 in 131,072) and 18 heads in a row (1 in 262,144)
1000.0589% or about 1 in 1,700Roughly the same order as rolling four sixes in a row (1 in 1,296)
1,0005.78% or about 1 in 17No longer remote; at this burst size collisions become operationally noticeable

So 2^23 does not eliminate collisions, but it pushes them far enough out that ordinary workloads can usually ignore them while still avoiding the coordination costs of counters, node IDs, or central allocation.

What Randomness Is Doing

Classic Snowflake designs use lower bits for machine identity and local sequence counters. simpleflakes uses those lower bits for randomness instead.

This changes the contract:

  • no machine registration is needed
  • no counter state is needed
  • no per-process synchronization is needed
  • uniqueness within the same millisecond becomes probabilistic rather than deterministic

That tradeoff is intentional.

For many systems, removing coordination is worth more than guaranteeing collision-free generation inside one millisecond on one machine. For systems that need deterministic same-millisecond uniqueness, a node-and-sequence design may be a better fit.

By default, simpleflakes fills those 23 bits with cryptographically secure random values via Web Crypto. The runtime behavior is validated in tests/integration.test.js.

The Resulting Invariants

Once the layout is fixed, several invariants fall out directly.

1. Round-trip parsing is straightforward

Generation and parsing are inverses:

generate: flake = ((ts - epoch) << 23) | randomBits
parse ts:  ((flake >> 23) + epoch)
parse rnd: (flake & ((1 << 23) - 1))

That is why parseSimpleflake(simpleflake(ts, randomBits, epoch)) can recover the original components when the inputs stay within the expected bit ranges. This behavior is covered by tests/parseSimpleflake.test.js.

2. Integer ordering tracks timestamp ordering

Because timestamp bits are more significant than random bits, a later timestamp produces a larger integer when all other assumptions are held constant. This is tested in tests/simpleflake.test.js.

3. IDs are fixed-width and compact

The binary form is always 64 bits when padded. This is exactly what binary() exposes.

4. Same-millisecond uniqueness is statistical

Two generators can produce the same ID only if they use the same timestamp, epoch, and random bits. With 23 random bits, the collision space per millisecond is 8,388,608 possibilities.

This is strong enough for many workloads, but it is not a proof of uniqueness. It is a probability distribution.

What the Library Guarantees

Given valid inputs and a consistent epoch, simpleflakes guarantees:

  • a 64-bit bigint representation
  • recoverable timestamp and random components
  • integer sorting that follows timestamp ordering across different milliseconds
  • no dependence on shared machine IDs or sequence counters
  • default use of cryptographically secure randomness when available

What the Library Does Not Guarantee

Just as important, simpleflakes does not guarantee:

  • deterministic collision-free generation inside the same millisecond
  • generation order within the same millisecond
  • protection from out-of-range caller-provided values
  • infinite timestamp range

Those are not oversights. They are direct consequences of the chosen constraints.

The implementation keeps the hot path minimal. If you pass explicit randomBits, it does not clamp them into 23 bits for you. In other words, the bit budget is part of the caller contract, not just an internal detail.

Why This Design Is Fast

From a first-principles performance perspective, the algorithm is cheap because generation is mostly:

  • one timestamp read
  • one random value read or one caller-provided value
  • one subtraction
  • one left shift
  • one bitwise OR

There is no lock, no counter increment loop, no machine-ID lookup, and no network coordination.

That is why the hot path in src/simpleflakes.ts stays extremely small.

Mapping the API Back to the Model

The public API is just a thin layer over the bit model.

  • simpleflake() builds the integer from time plus randomness.
  • parseSimpleflake() reverses the layout into timestamp plus random bits.
  • extractBits() is the low-level primitive for pulling fields from the integer.
  • binary() is a visualization helper for seeing the layout directly.
  • SimpleflakeStruct is the parsed representation.

Nothing here is arbitrary. Each function exists because the format is a packed 64-bit value.

Operational Implications

If you are using simpleflakes in production, the first-principles view leads to a few practical rules:

  1. Store IDs as bigint or as strings at system boundaries.
  2. Keep the epoch consistent across all producers and consumers.
  3. Treat caller-supplied randomBits as a low-level input that must remain within 0..8388607.
  4. Do not assume same-millisecond generation order.
  5. If you need deterministic uniqueness under extreme same-millisecond concurrency, use a higher-level coordination strategy or a different ID scheme.

The One-Sentence Summary

simpleflakes is a 64-bit bigint ID scheme that puts elapsed time in the high 41 bits and randomness in the low 23 bits so distributed systems can generate sortable, compact IDs without coordination.