The prompt
"Design a unique ID generator" looks like a one-liner (use a UUID) until the interviewer adds constraints: 64 bits, sortable by time, very fast, and no single machine that everything depends on.
Every service at Snip needs IDs for new rows: clicks, events, messages. Design a way to generate them: unique across all services and both regions, 64-bit integers, roughly ordered by creation time, at up to 500,000 a second overall, with no single point of failure and under a millisecond to get one.
1. Requirements
- Unique, everywhere, forever.
- 64 bits, so they fit a
bigintcolumn and are cheap to index. - Roughly time-ordered ("k-sorted"): IDs made later are usually larger, so new rows land at the end of a B-tree index (04.02 Indexes and B-trees) and IDs sort by time without a separate column.
- Fast and available: 500,000 a second, under a millisecond, no single point of failure.
- Not required: being unguessable (these aren't Snip's short codes) or strictly sequential.
2. Options
Deep dive
Which scheme?
Unique and ordered, but every insert in every service depends on one machine, and it's a bottleneck for both regions.
Flickr's two ticket servers hand out odd and even numbers; a service can take a block of 1,000 at a time to cut round trips. Still a dependency, and blocks break time order. UUIDv4 needs no coordination but is 128 bits and random, which scatters index inserts; UUIDv7 fixes the order, and is the right answer if 128 bits is acceptable (05.05 Distributed ID generation).
Each generator makes IDs alone from its clock, its worker number and a counter. 64 bits, time-ordered, no network call per ID.
1 bit | 41 bits | 10 bits | 12 bits
unused | milliseconds since 2025-01-01 | worker number | sequence within the millisecond
41 bits of milliseconds ≈ 69 years · 1,024 workers · 4,096 IDs per worker per millisecondOne worker can make about 4 million IDs a second, so 500,000 a second needs a handful. The real limits are the 1,024 worker numbers and the clock.
3. High-level design
4 steps. Use Next and Back to follow the request.
Text version
- At start-up, each process claims a free worker number from 0 to 1,023, with a 30-second lease.
- It then makes IDs entirely in memory: current millisecond, its worker number, and a sequence that resets each millisecond. No network call per ID.
- It renews the lease every 10 seconds. If a renewal fails and the lease runs out, it stops making IDs, because another process may now hold that number.
- Clocks are kept close by NTP; the generator also checks every timestamp against the last one it used.
- Service process → Worker-number leases: claim, renew every 10 s
- Service process → Worker-number leases
- Time sync → Service process
4. Deep dives
Deep dive
Giving each generator a unique worker number
Works until someone copies a config, or autoscaling starts two copies of the same one. Duplicate worker numbers mean duplicate IDs, silently.
No coordination, but 10 bits from a hash of an address collide: with 40 machines, the chance that two share a number is about 50%, and with 100 it's about 99%. Containers reuse addresses, too.
A process claims a free number with an expiry and renews it; if renewal fails, it stops before the lease expires. A process that stalled or lost its network can't keep using a number someone else now holds (08.07 Leader election and leases).
CREATE TABLE worker_ids (id int PRIMARY KEY, holder text, expires_at timestamptz NOT NULL DEFAULT '-infinity');
INSERT INTO worker_ids (id) SELECT g FROM generate_series(0, 1023) g;export async function claimWorkerId(db: pg.Pool, holder: string, leaseSeconds = 30): Promise<number | null> {
const { rows } = await db.query(
`UPDATE worker_ids SET holder = $1, expires_at = now() + make_interval(secs => $2)
WHERE id = (SELECT id FROM worker_ids WHERE expires_at < now()
ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED)
RETURNING id`,
[holder, leaseSeconds],
);
return rows[0]?.id ?? null;
}
export async function renewWorkerId(db: pg.Pool, id: number, holder: string, leaseSeconds = 30) {
const { rowCount } = await db.query(
`UPDATE worker_ids SET expires_at = now() + make_interval(secs => $3)
WHERE id = $1 AND holder = $2 AND expires_at > now()`,
[id, holder, leaseSeconds],
);
return rowCount === 1; // false: the lease is gone, so stop generating IDs at once
}Tested on Postgres 16 with 2-second leases: 20 processes claiming at the same moment got 20 different numbers. Renewal by the holder succeeded and by any other name failed. After the lease ran out, the old holder's renewal failed, and a new process claimed the freed number. The lease time comes from the database's clock, so the processes' own clocks don't decide who holds what.
Deep dive
When the clock goes backwards
NTP steps the clock back 50 ms; the generator reuses those 50 milliseconds with the same worker number and sequences that start at 0 again: duplicate IDs.
Snowflake's own rule. Remember the last timestamp used; if the clock reads earlier, wait or return an error. Correct, but a large jump stalls the process.
Wait out small steps (a few milliseconds); for large ones, stop, alert and let the process give up its lease and claim a new number. Configure time sync to slew (adjust gradually) rather than step, and keep the lease longer than the largest step you'd tolerate, so a restarted process can't reuse its old number in the repeated time.
Library or service? A library has no network hop and no new service to run; it needs the lease table and disciplined clocks on every machine. A central ID service (a few instances, each with its own worker number) suits clients in many languages, at the cost of a call per ID or per block of IDs. Snip uses the library.
5. Wrap-up
What breaks first: worker numbers, if thousands of short-lived processes each hold one (10 bits is 1,024); widen the worker field and narrow the sequence. Then ordering across machines: IDs are only roughly ordered, since two machines' clocks differ by a few milliseconds, so never use them to decide which of two events happened first (08.05 Clocks and ordering).
What's expected at each level
Junior
gets a working design
- Proposes UUIDs or a database sequence and names a drawback
Mid-level
explains the trade-offs
- Designs the Snowflake layout and does the arithmetic
- Explains why time order helps indexes
Senior
drives it and goes deep
- Assigns worker numbers with leases and stops when a lease is lost
- Handles a backwards clock
- Knows IDs are only roughly ordered across machines
Seen in production
Snowflake
Moving tweets from MySQL to Cassandra, which has no way to generate sequential IDs, Twitter needed at least 10,000 IDs a second per process, generated without machines coordinating, roughly sorted by time and under 64 bits. The design was a 41-bit millisecond timestamp with a custom epoch, a 10-bit machine number and a 12-bit sequence. The README warns that Snowflake depends on NTP and refuses to generate IDs if the clock runs backwards.
What we foundUncoordinated, roughly time-ordered 64-bit IDs: timestamp, machine number and sequence, and a refusal to run when the clock goes backwards.
Snowflake (2010 release README)github.com ↗Flickr
2010Ticket servers
Flickr's sharded MySQL couldn't rely on auto-increment, which isn't unique across databases, and it rejected GUIDs
because they're big and index badly. Its ticket servers each hold one row per sequence and use REPLACE INTO with
LAST_INSERT_ID() to atomically get the next number. Two servers, configured to step by 2 from offsets 1 and 2,
split the space into odd and even, so either can fail.
What we foundTwo small MySQL servers, one handing out odd IDs and one even, gave sharded data unique 64-bit keys.
Ticket Servers: Distributed Unique Primary Keys on the Cheapcode.flickr.net ↗Autoscaling starts a second copy of a service whose config says worker = 17, so two processes generate IDs as worker 17. What happens?
Recap
Interview questions
Design a distributed unique ID generator.