all_lessons / system_design / cases / C04 C04 / C44

Design a distributed unique ID generator

You have 64 bits and a fleet of machines that don't trust each other's clocks. Every requirement — ordering, sharding, scale, privacy, recovery — is a fight over how you spend those bits. The design is a contract hidden inside a number.

Source: Vol. 1 Ch. 7, archive Case drill Trade-off first
How to read this case
This page is an original educational walkthrough based on the case topic, not a transcription. Read it linearly: the bit budget, the contract, the estimates that fall out of the arithmetic, the data model, the generation flow, then the deep dives where the clock turns a "trivial" counter into a distributed-systems problem.
The hinge: a fixed-width integer with no central coordinator
What makes this problem forcing is a single sentence: generate billions of unique 64-bit IDs per second across a fleet, with no per-ID round trip to a central authority, and have them come out roughly time-sorted. Drop any one clause and it's easy. Allow a central allocator and you have a counter behind a SPOF. Drop "64-bit" and you use a 128-bit UUID. Drop "time-sorted" and randomness solves uniqueness for free. Keep all three and you are forced into the Snowflake shape: encode identity into the bits themselves so that uniqueness is structural — no two machines can ever collide because the worker-ID field guarantees it — and ordering is free because the high bits are a timestamp. The entire design is then a negotiation over how to divide 64 bits among three competing claims.

1. The bit budget — the load-bearing arithmetic

Start here, because every later decision is just a consequence of this division. Twitter's Snowflake spends 64 bits like this:

63 0 ┌─┬───────────────────────────────────┬──────────┬────────────┐ │0│ 41-bit timestamp (ms) │ 10-bit │ 12-bit │ │ │ ms since a custom epoch │ worker │ sequence │ └─┴───────────────────────────────────┴──────────┴────────────┘ ▲ ▲ ▲ ▲ sign 1 + 41 = 42 +10 = 52 +12 = 64 bits (always 0 → positive signed int64, plays nice with every DB) decoded example id = 7026338326634471424 timestamp bits → 1675209600123 ms → 2023-02-01 00:00:00.123 UTC worker bits → 42 → datacenter-A / box-42 sequence bits → 0 → first ID minted that millisecond

Add them up out loud: 1 + 41 + 10 + 12 = 64 bits. The sign bit is held at 0 so the value is always a positive int64 — that matters because it slots into a standard signed BIGINT column without games. Now turn each field into a capacity number, because those numbers are the design constraints:

Multiply the last two for the theoretical ceiling: 4096 \text{ IDs/ms/worker} \times 1024 \text{ workers} = 4{,}194{,}304 IDs per millisecond, i.e. ~4.2 billion IDs/ms, or ~4.2 trillion IDs/second fleet-wide. That number is comically large on purpose — it tells you the design is never globally bottlenecked. The bottleneck is always local, and that is the next paragraph.

Lifespan (41-bit ms)
≈ 69.7 yr
Burst / worker / ms (2¹²)
4096
Workers (2¹⁰)
1024
Fleet ceiling / ms
≈ 4.2 B
The overflow that decides your design
The fleet ceiling is irrelevant; the per-worker ceiling is what bites. Suppose one hot worker must mint 5000 IDs in a single millisecond. Its sequence field tops out at 4096. The remaining 5000 - 4096 = 904 IDs have nowhere to go inside that tick. The generator has exactly two honest moves: (a) block — busy-wait until the clock rolls to the next millisecond and reset the sequence to 0, smearing the burst across 2 ms; or (b) borrow bits — re-allocate the layout so sequence has more room (13 bits → 8192/ms) at the cost of a shorter lifespan or smaller fleet. What it must never do is wrap the counter and reuse a (timestamp, worker, sequence) triple — that is a silent duplicate. Choosing (a) vs (b) is a back-of-envelope decision driven entirely by your real peak throughput per worker.

2. Clarify the contract

Treat the prompt as a product contract before a box diagram. What it must do:

And — just as important in an interview — what it need not do:

3. Define the surface area

Keep the API tiny. It should express the operation, not leak the bit layout.

API / operationWhy it exists
next_id() -> int64The hot path. Local, lock-light, no network. Returns a packed 64-bit ID.
reserve_worker_id(host) -> worker_idCalled once at boot. Leases a unique worker ID from the registry — the single point where coordination happens (lesson 10).
decode(id) -> (timestamp, worker, sequence)Unpacks the fields. Useful for debugging and sharding — and the source of the privacy leak in deep dive 2.

4. Model the data

The state is deliberately minimal. Per-worker, in memory, the generator holds only: the worker ID (leased at boot), the last timestamp it emitted (to detect rollback and ms-rollover), the sequence counter for the current ms, and the custom epoch constant. The only shared, durable state is the worker-ID registry — and that is exactly where the consistency requirement concentrates.

worker_id (leased)last_timestampsequencecustom epochworker registry (consensus)

5. Linearized design — follow one next_id()

Walk the design in the order a single call actually executes. The bottleneck exposes itself at step 4.

  1. At boot: the worker calls reserve_worker_id() and leases a 10-bit ID from a strongly consistent registry (ZooKeeper/etcd). The lease is fenced (lesson 10) so a hung-then-resumed worker can't keep using a reassigned ID.
  2. Read the clock: now = current_millis().
  3. Rollback guard: if now < last_timestamp, the clock went backward — do not emit. Wait, or fail. (Deep dive 1; this is the whole point of the case.)
  4. Sequence step: if now == last_timestamp, increment sequence. If that overflows 4096, busy-wait to the next millisecond and reset the sequence — the local bottleneck from §1. If now > last_timestamp, reset sequence = 0.
  5. Pack: id = ((now - epoch) << 22) | (worker_id << 12) | sequence. Update last_timestamp. Return.
boot: reserve_worker_id() ──lease(fenced)──▶ ┌──────────────┐ │ worker-ID │ │ registry │ (etcd / ZK, │ (consensus) │ strongly └──────────────┘ consistent) hot path: next_id() ── NO network ──▶ ┌───────────────────────────┐ │ read clock │ │ now < last? → WAIT/FAIL │ ◀ clock guard │ now == last? → seq++ │ │ seq > 4095 → spin to │ ◀ local bottleneck │ next ms │ │ pack: ts | worker | seq │ └───────────────────────────┘ │ ▼ int64, k-sorted

6. The allocator — trading bits across requirements

There is no universal split. The "right" layout is whichever survives your worst-case peak throughput and your fleet size and your required lifespan. Read this table as: pick the row whose three capacity numbers all clear your requirements; the flagged rows are where a target throughput overflows the sequence budget and forces blocking.

Bit split (ts / worker / seq)LifespanMax workersIDs / ms / workerVerdict
41 / 10 / 12 (classic Snowflake)≈ 69.7 yr10244096The default. Balanced; fine until one worker peaks > 4096/ms.
41 / 13 / 9≈ 69.7 yr8192512Huge fleet, but 512/ms/worker overflows easily — blocks under any real burst.
41 / 8 / 14≈ 69.7 yr25616384Few, very hot workers. Absorbs the 5000/ms case with headroom.
39 / 10 / 13≈ 17.4 yr10248192Buy burst capacity by spending lifespan. Clears 5000/ms; re-epoch in ~17 yr.
42 / 12 / 9≈ 139 yr4096512Long life + big fleet, starved sequence — overflows at > 512/ms.

Concretely: the 5000-IDs/ms hot worker from §1 fails on rows 1, 2 and 5 (it exceeds 4096, 512, 512) and must block. It passes on rows 3 and 4. Row 4 is the instructive trade: spending 2 timestamp bits drops lifespan from 69.7 to ~17.4 years but doubles per-worker burst to 8192/ms — a deliberate exchange of future capacity for present throughput. There is no free lunch; 64 bits is a zero-sum budget.

7. Deep dives

7.1 The clock is part of correctness — what happens on NTP step-back

A time-ordered ID generator is a distributed system, because clocks lie. This is DDIA Ch. 8's core warning: the time-of-day clock (the wall clock NTP disciplines) can jump backward when NTP corrects drift, and a VM resumed from a pause/migration can see time leap or — worse for us — appear to stall and then jump. If now ever goes backward and we naively keep packing IDs, we can re-mint a (timestamp, worker, sequence) triple that already exists → a duplicate primary key, the one failure the whole design exists to prevent.

The disciplines, in order of preference:

7.2 Decodable IDs leak time and region

The very property that makes Snowflake nice — decode(id) hands you creation time and worker/region — is an information leak when the ID is public. Two concrete leaks: (1) the timestamp bits expose exactly when a record was created — scraping the IDs of new signups reveals your growth rate and signup timing to a competitor (the classic "enumerate user IDs to estimate company size" attack); (2) the worker bits can encode which datacenter/region served the request. For internal IDs this is a debugging gift. For IDs exposed in URLs it is a business-intelligence and privacy gift to outsiders. The honest options: keep Snowflake IDs internal and hand out a separate opaque public handle, or accept the leak when it doesn't matter (most internal records).

7.3 UUIDv4 vs UUIDv7 — the index-locality argument

The reflexive alternative is "just use UUIDs, no coordination at all." That works for uniqueness but collides head-on with how databases store primary keys. UUIDv4 is 122 random bits: consecutive inserts land at random positions in a clustered B-tree index, so the working set of dirty index pages is huge, page splits are frequent, and write throughput craters. (DDIA Ch. 3: B-tree writes love locality; random keys are the pathological anti-pattern.) Snowflake's high-bit timestamp gives the opposite — near-sequential inserts that append to the right edge of the tree, hot in cache. UUIDv7 is the modern compromise: a 48-bit Unix-ms timestamp prefix + random tail, so it is time-sortable like Snowflake (good B-tree locality, k-sorted) yet needs no worker-ID coordination at all — the random bits handle uniqueness probabilistically. The price is 128 bits instead of 64 (bigger keys, bigger indexes) and no clean decode of worker/region. If you can afford 128-bit keys and want zero coordination, reach for UUIDv7; if you need 64-bit keys or want decodable region bits, Snowflake earns its complexity.

clustered B-tree, insert pattern over time UUIDv4 (random) UUIDv7 / Snowflake (time-prefixed) ┌─┬─┬─┬─┬─┬─┬─┬─┐ ┌─┬─┬─┬─┬─┬─┬─┬─┐ │ │▓│ │▓│ │ │▓│ │ │ │ │ │ │ │ │ │▓│ ← all writes hit │▓│ │ │ │▓│ │ │▓│ │ │ │ │ │ │ │ │▓│ the right edge, └─┴─┴─┴─┴─┴─┴─┴─┘ └─┴─┴─┴─┴─┴─┴─┴─┘ page stays hot splits everywhere, sequential append, whole tree is "hot" cache-friendly, few splits

8. Trade-offs

ChoiceBuysCostsChoose when
Central allocator vs local generatorsimple, strict global orderingavailability bottleneck + per-ID round tripsmall systems with a true total-order need
Random UUIDv4 vs time-sortable (Snowflake / UUIDv7)zero coordination, no clock dependencyindex-locality loss, no ordering, 128-bit keysfully decentralized external identifiers where DB locality is irrelevant
More worker bits vs more sequence bitslarger fleetlower per-worker burst (overflow → blocking)many small, low-burst generators
Opaque ID vs decodable IDprivacy (no time/region leak)loses debugging + sharding-by-time valuepublic URLs and enumerable resources

The sharpest of these is the first. A central allocator (a single counter, or a DB sequence) is seductively simple and gives strict monotonic order — but it puts a network round trip and a SPOF on the hot path of every single insert in your system. The Snowflake bet is to trade strict ordering for availability: accept that two IDs in the same ms across workers are unordered, in exchange for a generator that never makes a network call on the hot path and survives the registry being down (you only need it at boot). That is the same availability-vs-coordination tension as lesson 09's CAP discussion, applied to a 64-bit integer.

9. Failure modes

FailureMitigation
Duplicate worker IDs (two boxes lease the same ID)Lease worker identity from a strongly consistent registry (etcd/ZK) and fence expired leases (lesson 10) so a resumed-after-pause worker can't reuse a reassigned ID.
Clock rollback (NTP step-back, VM resume)Gate on a monotonic clock; on small step wait it out, on large step refuse + alert. Never emit while now < last_timestamp.
Sequence overflow (> 4096/ms on one worker)Busy-wait to the next millisecond and reset sequence; or re-allocate bits (§6) for permanently hot workers.
Hot DB inserts (right-edge contention)Time-ordered IDs are intentionally right-heavy — good for locality, bad for write-hotspotting one shard. Add a shard-suffix or hash-prefix when write throughput on the newest partition saturates.

10. Interview prompts you should be ready for

  1. NTP steps the clock backward 50 ms — what does your generator do? (senior answer) It must never emit while now < last_timestamp, because that risks re-minting a (ts, worker, seq) triple — a duplicate. For a 50 ms step I busy-wait until the monotonic clock catches back up to last_timestamp, then resume; a brief generation stall is fine. For a large jump I refuse and alert rather than spin. The guard should compare against a monotonic clock (DDIA Ch. 8), not the wall clock NTP just corrected, so the correction can't fool it. Optionally a reserved drift-bucket bit preserves uniqueness without waiting.
  2. Walk me through the bit budget. (senior answer) 1 sign + 41 timestamp + 10 worker + 12 sequence = 64. 2⁴¹ ms ≈ 69.7 years from a custom epoch; 2¹² = 4096 IDs/ms/worker; 2¹⁰ = 1024 workers; product ≈ 4.2 billion IDs/ms fleet-wide. The fleet number is irrelevant — the real ceiling is 4096/ms on a single hot worker.
  3. One worker needs 5000 IDs in a millisecond. Now what? (senior answer) 5000 > 4096, so the sequence field overflows. Either block — spin to the next ms and reset sequence, smearing the burst across 2 ms — or re-allocate bits (e.g. 39/10/13 gives 8192/ms at the cost of dropping lifespan to ~17 yr). I'd never wrap and reuse a sequence value; that's a silent duplicate.
  4. Why not just use UUIDv4 everywhere? (senior answer) Uniqueness is free, coordination is zero — but 122 random bits scatter clustered-B-tree inserts (DDIA Ch. 3), causing page splits and tanking write throughput, and they're not sortable. UUIDv7 fixes both with a time prefix and still needs no coordination, at the cost of 128-bit keys. Snowflake wins when you need 64-bit keys or decodable region bits.
  5. How are worker IDs assigned without collisions? (senior answer) Lease them from a strongly consistent registry (etcd/ZooKeeper) at boot, with a fencing token (DDIA Ch. 9 / lesson 10). The fence is the subtle part: a worker that pauses past its lease, then resumes, must be prevented from using the ID that's since been reassigned — otherwise two boxes share a worker ID and collide.
  6. What does a public Snowflake ID leak? (senior answer) Creation time (timestamp bits) and often region/datacenter (worker bits). Enumerating signup IDs reveals growth rate and timing to competitors. If IDs are public and that matters, mint internal Snowflakes but expose a separate opaque handle.
  7. Is this strictly monotonic / a total order? (senior answer) No — it's k-sorted. Two IDs minted in the same ms on different workers have no defined order. If you need a true global total order, that's a sequencer/consensus problem (lesson 10), not an ID generator; conflating the two is a common mistake.

Related foundation lessons

This case leans on three foundations. The worker-ID assignment is the textbook use of lesson 10 (consensus & coordination) — a strongly consistent registry plus fencing tokens (DDIA Ch. 9) to stop a paused-then-resumed worker reusing a reassigned ID. The reason we want time-sortable IDs at all is lesson 04's (data modeling & storage) B-tree write-locality argument (DDIA Ch. 3): sequential keys append to the index's hot right edge, random keys (UUIDv4) thrash it. And the flip side — time-ordered keys concentrating writes on the newest partition — is exactly lesson 07's (partitioning) hot-shard problem, which a shard-suffix mitigates. The decode/clock discipline draws on DDIA Ch. 8 (unreliable clocks; monotonic vs time-of-day).

Method and napkin math Consensus Data modeling and storage Partitioning
Takeaway
A distributed ID generator is the art of spending 64 bits well. 1 + 41 + 10 + 12 buys ~69.7 years, 1024 workers, and 4096 IDs/ms each — and the only real bottleneck is that per-worker 4096, which a hot worker overflows and must answer by blocking or re-allocating bits. Uniqueness comes from structure (the worker field), not coordination, so the hot path makes no network call. The two things that turn this from a counter into a distributed-systems problem are the clock — which can step backward and must never be allowed to mint a duplicate — and worker-ID leasing, which needs consensus and fencing. Decode the ID and you read its birth time and home region: a debugging gift internally, a privacy leak in public. When 64 bits and decodability aren't required, UUIDv7 buys you the same time-ordering with zero coordination.