Design a Unique ID Generator (Snowflake)
Design a service that hands out unique, roughly time-sorted 64-bit IDs at high throughput across many machines, in the style of Twitter Snowflake. The core challenge is generating IDs without coordination between nodes while keeping them sortable and collision-free.
1Requirements
Functional
- • Generate globally unique 64-bit numeric IDs with no duplicates across all machines.
- • IDs must be roughly sortable by creation time so newer IDs compare greater than older ones.
- • Support ID generation from many application servers or datacenters without a central coordinator on the hot path.
- • Expose a simple API or library call that returns an ID in a single round trip or in-process call.
- • Allow extracting the embedded timestamp from an ID for debugging and analytics.
Non-functional
- • Throughput of at least 10,000 IDs per second per machine, with headroom for bursts.
- • Latency under 1 millisecond per ID, ideally an in-process call with no network hop.
- • High availability: the generator must not become a single point of failure for every write in the system.
- • Correctness under clock skew: NTP adjustments or leap seconds must never produce duplicate IDs.
- • IDs should fit in a signed 64-bit integer so they work as primary keys in Postgres, MySQL, and Java longs.
2Back-of-envelope estimation
| Timestamp bits | 41 bits | 2^41 ms is about 69 years of millisecond timestamps from a custom epoch. |
| Machine capacity | 4,096 IDs/ms | 12 sequence bits give 2^12 = 4,096 IDs per millisecond per worker. |
| Peak per machine | 4.1M IDs/sec | 4,096 IDs/ms x 1,000 ms; far above any realistic single-node need. |
| Worker ID space | 1,024 workers | 5 datacenter bits x 5 machine bits = 32 x 32 = 1,024 unique generators. |
| Storage per ID | 8 bytes | A UUID is 16 bytes; at 10B rows the 64-bit ID saves about 80 GB in the primary index alone. |
3API design
GET /v1/idReturns a single new 64-bit ID as a JSON string (stringified to avoid JavaScript number precision loss beyond 2^53).
GET /v1/ids?count=100Batch endpoint returning up to 1,000 IDs in one call so clients amortize network overhead.
GET /v1/id/{id}/decodeDebug endpoint that unpacks an ID into its timestamp, datacenter ID, machine ID, and sequence number.
4High-level design
The Snowflake layout packs four fields into one 64-bit integer: 1 unused sign bit, 41 bits of milliseconds since a custom epoch, 5 bits of datacenter ID, 5 bits of machine ID, and 12 bits of per-millisecond sequence. Because the timestamp occupies the high bits, IDs sort by creation time, which keeps B-tree inserts append-friendly and lets you paginate by ID instead of a separate created_at column.
Each application server runs the generator as an in-process library. On startup it acquires a unique worker ID (datacenter + machine) from a small coordination store such as ZooKeeper, etcd, or even a database table with a unique constraint. After that, ID generation requires no network calls at all: the node reads its local clock, increments a sequence counter, and bit-shifts the fields together.
Within one millisecond, the 12-bit sequence counter distinguishes IDs. If a node exhausts 4,096 IDs in a single millisecond, it spins until the next millisecond tick. Across milliseconds the counter resets to zero. Across machines, uniqueness comes from the worker ID bits, so two machines can never collide even at the exact same timestamp and sequence.
Alternatives worth naming in an interview: UUIDv4 is coordination-free and simple but is 128 bits, random (terrible for B-tree locality), and not time-sortable; UUIDv7 fixes sortability but is still 16 bytes. A database ticket server (Flickr style) uses REPLACE INTO on an auto-increment table, which is simple but adds a network hop and a single point of failure, usually mitigated by two servers handing out odd and even IDs. Snowflake is the sweet spot when you need compact, sortable, high-throughput IDs.
5Data model
worker_registry
worker_id, datacenter_id, machine_id, hostname, leased_until, created_atStored in etcd or a small SQL table; each node leases a worker_id at boot and renews it.
id_layout (conceptual)
sign(1), timestamp_ms(41), datacenter_id(5), machine_id(10 combined), sequence(12)Not a table; the bit layout of the 64-bit integer itself.
6Deep dives
Handling clock skew and backwards clocks
The generator trusts the local clock, so a backwards clock jump is the main correctness hazard. If NTP steps the clock back 50 ms, the node could re-issue timestamps it already used, and with a repeated sequence number that means duplicate IDs. The standard defense is to remember the last timestamp used: if the current clock reads earlier than that, either refuse to generate (throw and let the caller retry) or spin-wait until the clock catches up if the skew is small, say under 10 ms.
Operationally, you prevent large jumps by running NTP in slew mode (which speeds up or slows down the clock gradually instead of stepping it) and by refusing to start the generator if the clock looks wildly wrong compared to the lease store. Some implementations also reserve a few bits or a fallback sequence range to survive small regressions without blocking.
A subtler issue is worker ID reuse: if a node dies and another node takes its worker ID while the old process is still running (a zombie), both generate with the same worker bits. Leases with TTLs plus a startup wait of one lease period close this hole.
Why not just UUIDs or auto-increment
UUIDv4 needs zero coordination and never blocks, which is genuinely attractive. The costs: 16 bytes instead of 8, no time ordering, and random inserts that scatter writes across the entire primary key B-tree, causing page splits and cache misses at scale. UUIDv7 embeds a millisecond timestamp in the high bits and largely fixes locality, so it is the modern default when 128 bits are acceptable.
Database auto-increment is perfect on a single node but breaks under sharding: two shards will both hand out ID 1001. Workarounds include offset-and-stride (shard 1 issues 1, 3, 5 and shard 2 issues 2, 4, 6) or Flickr-style ticket servers, where a dedicated MySQL pair with auto_increment_increment=2 hands out blocks. Both add either operational rigidity or a network hop to every insert.
Snowflake trades a small amount of setup complexity (worker ID assignment, clock discipline) for coordination-free generation, compact sortable IDs, and per-node throughput that no ticket server can match.
Choosing the bit budget
The 41-5-5-12 split is a default, not a law. Every bit you move is a tradeoff along three axes: lifespan (timestamp bits), fleet size (worker bits), and burst rate (sequence bits). 41 timestamp bits from a 2020 epoch last until roughly 2089. If you only ever run 64 generators, you can shrink worker bits to 6 and give the extra bits to the sequence, doubling per-millisecond capacity.
Some systems use second-level rather than millisecond timestamps with a much larger sequence, which tolerates coarse clocks better but weakens sort granularity. Instagram's variant uses 41 bits of time, 13 bits of logical shard ID, and 10 bits of a per-shard sequence generated inside Postgres itself, showing the same idea can live inside the database as a PL/pgSQL function.
Whatever split you choose, publish it as a constant and never change it in place: reinterpreting the bits of already-issued IDs silently corrupts ordering and decoding.
7Rapid implementation: build the MVP
Theory is table stakes. Here is how you would stand up a working version fast, on a budget, with the core algorithm in real code.
Stack
Node.js (TypeScript) library + a 3-line etcd or Postgres worker-ID lease, embedded in any API server; zero extra infrastructure.
- 01Pick a custom epoch (e.g. 2024-01-01T00:00:00Z) and define the bit layout as constants: 41 timestamp, 10 worker, 12 sequence.
- 02Write the generator class with BigInt bit-shifting and a lastTimestamp guard that throws on clock regression.
- 03Create a worker_leases table in Postgres with a UNIQUE(worker_id) constraint; on boot, INSERT the first free ID in 0..1023 with a leased_until timestamp.
- 04Add a renewal loop that extends the lease every 30 seconds and kills the process if renewal fails twice.
- 05Wrap the generator in a GET /v1/id route that returns the ID as a string, plus a /decode route for debugging.
- 06Load test with autocannon at 50k req/sec and verify zero duplicates by inserting all IDs into a UNIQUE column.
- 07Add a unit test that mocks Date.now going backwards and asserts the generator throws instead of duplicating.
Snowflake bit-packing generator
typescriptconst EPOCH = 1704067200000n; // 2024-01-01 UTC
const WORKER_BITS = 10n;
const SEQ_BITS = 12n;
const MAX_SEQ = (1n << SEQ_BITS) - 1n; // 4095
export class Snowflake {
private lastTs = -1n;
private seq = 0n;
constructor(private workerId: bigint) {
if (workerId < 0n || workerId > 1023n) throw new Error("worker id out of range");
}
next(): bigint {
let ts = BigInt(Date.now());
if (ts < this.lastTs) throw new Error("clock moved backwards");
if (ts === this.lastTs) {
this.seq = (this.seq + 1n) & MAX_SEQ;
if (this.seq === 0n) {
while (ts <= this.lastTs) ts = BigInt(Date.now()); // spin to next ms
}
} else {
this.seq = 0n;
}
this.lastTs = ts;
return ((ts - EPOCH) << (WORKER_BITS + SEQ_BITS)) | (this.workerId << SEQ_BITS) | this.seq;
}
}Decode an ID back into its fields
typescriptconst EPOCH_MS = 1704067200000; // must match the generator's epoch
export function decode(id: bigint) {
const seq = id & 0xfffn; // low 12 bits
const workerId = (id >> 12n) & 0x3ffn; // next 10 bits
const tsOffset = id >> 22n; // high 41 bits
const createdAtMs = Number(tsOffset) + EPOCH_MS;
return {
createdAt: new Date(createdAtMs),
createdAtMs,
workerId: Number(workerId),
sequence: Number(seq),
};
}Worker ID lease in Postgres
sqlCREATE TABLE worker_leases (
worker_id INT PRIMARY KEY CHECK (worker_id BETWEEN 0 AND 1023),
hostname TEXT NOT NULL,
leased_until TIMESTAMPTZ NOT NULL
);
-- Claim the first free or expired worker id atomically
INSERT INTO worker_leases (worker_id, hostname, leased_until)
SELECT gs.id, 'api-7', now() + interval '60 seconds'
FROM generate_series(0, 1023) AS gs(id)
WHERE NOT EXISTS (
SELECT 1 FROM worker_leases w
WHERE w.worker_id = gs.id AND w.leased_until > now()
)
ORDER BY gs.id
LIMIT 1
ON CONFLICT (worker_id) DO UPDATE
SET hostname = EXCLUDED.hostname, leased_until = EXCLUDED.leased_until
WHERE worker_leases.leased_until <= now()
RETURNING worker_id;Bottlenecks & failure modes
- ⚠Backwards clock jumps can cause duplicates; must track last timestamp and block or error on regression.
- ⚠Worker ID assignment is the one coordination point; a misconfigured duplicate worker ID silently produces collisions.
- ⚠The 4,096 per-millisecond sequence cap makes a single node spin under extreme bursts; batch requests or add nodes.
- ⚠JavaScript clients corrupt IDs above 2^53 if APIs return them as JSON numbers; always serialize as strings.
- ⚠A centralized ID service (instead of an in-process library) puts a network hop and a failure domain on every write path.
Key takeaways
- ▸Uniqueness comes from partitioning the ID space (worker bits), not from coordination on the hot path.
- ▸Time-sortable IDs double as creation timestamps and keep B-tree inserts sequential.
- ▸Clock skew is the central failure mode; the last-timestamp guard is non-negotiable.
- ▸Know the alternatives cold: UUIDv4 (simple, fat, unsorted), UUIDv7 (sorted, still 16 bytes), ticket servers (simple, centralized).
- ▸Bit budgets are tunable: lifespan vs fleet size vs burst throughput.