Courses / System Design Interview / Chapter 11
Module 2 · Building Blocks · Chapter 11 of 39
Unique IDs and Sequencers
Builds on Abstractions That Hide the Network, Databases: Replication, Partitioning and Choosing a Store, Consensus, Leader Election and Coordination and Designing a Key-Value Store. Next up: Monitoring: Metrics, Logs, Traces and Alerts.
Part 1 · What a Good ID Needs
Why IDs are harder than they look
It is 8:50 am at a tea stall outside Pune station, and forty commuters scan the same QR code in five minutes. Behind each scan, a payments app creates a transaction and gives it an ID. Last night the team split the transactions table across four database shards to survive the morning rush, and each shard kept its own auto-increment counter. By 9 am shards 1 and 3 have both issued transaction 5,000,214: one ₹12 chai and one ₹15,000 rent payment. When the chai seller refunds a customer, the refund job looks up 5,000,214 and reverses the rent.
This chapter follows that payments app. An ID is a value that names exactly one thing, forever. Uniqueness is the must-have; the other properties are choices that pull against each other:
| Property | Why you might want it |
|---|---|
| Unique | Always: a duplicate sends a refund to the wrong payment |
| Sorted by time | Newest-first lists, paging, index-friendly inserts |
| Compact | IDs sit in every row and every index, often many times over |
| Fast to create | No network hop or lock on the payment path |
| Hard to guess | Receipt links should not reveal volumes or invite scraping |
| Always available | If ID creation stops, every payment stops |
| Spreads writes | In a store split by key range, new keys should not all hit one node |
No scheme wins on every row, so each one in this chapter is a different set of trade-offs. The first is the one that broke at the tea stall.
Auto-increment, and where it breaks
Why did the counters collide? An auto-increment column gives rows 1, 2, 3 and so on, like a token machine at a bank counter. It is unique, compact, sorted by insert time and needs no extra system. For a single database server it is often the right answer.
It breaks in three ways as the payments app grows:
- One writer. Every ID comes from one database, so that server caps inserts and is a single point of failure for every payment.
- Shards collide. Once the table is partitioned, as in the databases chapter, every shard counts on its own. Shard 1's transaction 5,000,214 and shard 3's are different payments with one ID.
- It leaks. Sequential IDs tell outsiders your volume: pay once today and once tomorrow, and the difference is your daily count. They also let anyone try
/receipt/1001,/receipt/1002and onwards.
A classic patch gives each shard its own step. With 4 shards, shard 1 issues 1, 5, 9, shard 2 issues 2, 6, 10, and so on. IDs stay unique, but a fifth shard means changing every step, and IDs from different shards no longer sort by time. Auto-increment ties uniqueness to one counter, so it fails the moment there are several.
If auto-increment IDs stay inside the database, give receipt links a separate random ID. You keep compact internal keys without publishing your payment volume.
What if IDs did not come from a counter at all? The next page gives up on order entirely and picks them at random.
Part 2 · Random and Time-Based IDs
UUIDs: random and enormous
A random ID works like picking a lottery number from a space so vast that no two people ever pick the same one. A UUID (universally unique identifier) is 128 bits, usually written as 36 characters such as 3b9e2c71-8d4f-4a06-9c15-7e2d0a5b6f48. In version 4, 122 bits are random and the other 6 mark the version and variant.
Collisions are possible in principle. In practice you would need about a billion UUIDs every second for roughly 85 years before the chance of even one duplicate reached 50%, provided they come from a proper random generator. Here is what that buys and costs:
| Aspect | Random UUID (version 4) |
|---|---|
| Coordination | None: any server, or even the customer's phone, can create one |
| Size | 128 bits, twice a 64-bit integer; 36 characters as text |
| Sorted by time | No: creation order is lost |
| Guessable | No, so it is safe in receipt links |
Random UUIDs buy zero coordination with size and lost order.
The hidden cost is the index. A time-ordered key inserts at the right-hand edge of a B-tree index, which stays in memory. Random keys land on any page, so a big transactions table keeps reading cold pages from disk and splitting them, and inserts slow down. With a billion rows, the extra 8 bytes per ID also cost 8 GB in every index that holds the ID.
UUID version 7, standardised in 2024 (RFC 9562), and the similar ULID format put a millisecond timestamp in the first 48 bits and keep plenty of random bits, so IDs sort by creation time and need no coordination. They are still 128 bits.
A 64-bit ID built from time
Can an ID be compact, roughly sorted and made without coordination, all at once? Think of a receipt printed with the time, the counter number and a running count: two counters never print the same receipt, even in the same second. Pack the same three things into one 64-bit integer and you get a Snowflake-style ID:
| Bits | Field | Range | What it gives you |
|---|---|---|---|
| 1 | Sign bit, always 0 | 0 | The ID stays a positive signed integer |
| 41 | Milliseconds since a custom epoch | About 69 years | IDs sort by creation time |
| 10 | Machine id | 1,024 generators | Two generators never collide |
| 12 | Sequence within the millisecond | 4,096 per ms | About 4 million IDs a second per generator |
FieldSign bit, always 0
Range0
What it gives youThe ID stays a positive signed integer
FieldMilliseconds since a custom epoch
RangeAbout 69 years
What it gives youIDs sort by creation time
FieldMachine id
Range1,024 generators
What it gives youTwo generators never collide
FieldSequence within the millisecond
Range4,096 per ms
What it gives youAbout 4 million IDs a second per generator
Each field answers one question: when, who, and which one of that millisecond.
The custom epoch is a zero point you choose, such as 1 January 2026. Counting from a recent date rather than 1970 keeps all 69 years ahead of you. The sequence restarts at 0 every millisecond; a generator that wants a 4,097th ID within one millisecond waits for the next.
A worked example: 24,000,000,000 ms after the epoch (about 278 days in), payment server 7 issues its fourth ID of that millisecond, sequence 3. The ID is 24000000000 × 2^22 + 7 × 2^12 + 3 = 100,663,296,000,028,675. Shift it right by 22 bits and the time comes back, which helps when debugging a payment. The next page turns the layout into code.
The generator, line by line
Where does the generator run? Inside each payment server process, as a small library: making an ID is a function call of a few microseconds, with no network hop. Each process holds one machine id, so two processes on one machine need two ids.
import time, threading EPOCH_MS = 1_767_225_600_000 # 2026-01-01 00:00 UTC def now_ms(): return time.time_ns() // 1_000_000 - EPOCH_MS class IdGenerator: def __init__(self, machine_id): assert 0 <= machine_id < 1024 self.machine, self.last, self.seq = machine_id, -1, 0 self.lock = threading.Lock() def next_id(self): with self.lock: now = now_ms() if now < self.last: # clock went backwards if self.last - now > 10: raise RuntimeError('clock moved back; refusing') while now < self.last: # small step: wait it out now = now_ms() if now == self.last: self.seq = (self.seq + 1) & 0xFFF # 12 bits if self.seq == 0: # 4,096 used this ms while now <= self.last: now = now_ms() else: self.seq = 0 self.last = now return (now << 22) | (self.machine << 12) | self.seq
Three branches carry the design:
- Clock went back: wait out a step of up to 10 ms; refuse anything larger, so a timestamp is never reused.
- Same millisecond: add one to the sequence; after 4,096, wait for the next millisecond.
- New millisecond: restart the sequence at 0. The lock keeps two threads from taking the same sequence number.
Many servers, no coordination
The machine id is what makes this work across a fleet. As long as no two running generators share one, their IDs differ in those 10 bits even when the time and sequence match. Generators never talk to each other, or to anyone, while making IDs.
Predict before you watch: the registry that leases machine ids goes down for 20 seconds, and leases last 60 seconds. Which payment servers can still create IDs?
- Every server already running: each leased its machine id at startup and builds IDs locally. Only a server starting during the outage must wait.
Watch for the machine bits: each server leases one at startup, then never asks again
Read it as steps
Each server runs its own generator. IDs show as time·machine·sequence: t900·m1·s0 is millisecond 900, machine 1, sequence 0.
The only shared step is leasing a machine id, once per process, not once per ID. Everything else is local arithmetic, which is why one generator makes millions of IDs a second and why a short registry outage stops nothing. Clocks, though, can still hurt a generator that never talks to anyone; that comes after one more question: where machine ids come from.
Handing out machine ids
If two live generators ever share a machine id, they can issue identical IDs in the same millisecond. So how does each process get a unique one? There are three common answers:
| Method | How | Watch out for |
|---|---|---|
| Static config | Each server's config file states its number | Two containers started from one image get the same id |
| Leased from a coordination service | At startup a process claims a free number under a lease it keeps renewing | A process that cannot renew in time must stop issuing IDs |
| Derived from the network | Use part of the server's IP address | Address reuse in containers and clouds causes clashes |
Leasing is the safe default for fleets that scale up and down.
The lease rules come straight from the coordination chapter. Lease for, say, 60 seconds and renew every 20. If a process cannot renew before its lease ends, it stops issuing IDs, because the registry may hand that number to a new process. The registry, in turn, never reuses a number until its old lease has fully expired plus a margin.
Ten bits allow 1,024 live generators. That sounds like plenty, but containers that restart every few minutes each take a fresh number and leave old ones waiting out their leases. Size the machine field for peak churn, not for the steady fleet; if 1,024 is tight, move a bit or two from the sequence, which few generators fill.
Part 3 · Where Time-Ordered IDs Go Wrong
Clock skew and clocks that go backwards
A generator that talks to nobody still trusts one thing: its clock. Servers keep time with NTP, which keeps nudging each clock towards reference servers. Within a data centre, clocks usually agree within a few milliseconds, never exactly, and now and then a clock jumps backwards: after a large NTP correction, a leap second, or a wrong manual change.
Skew between machines only makes ordering approximate. If server 1 runs 3 ms ahead of server 2, its IDs sort as if made 3 ms later, so these IDs are roughly sorted: fine for a payment history, not proof of which payment happened first. When order across machines matters, use a hybrid logical clock: physical time plus a small counter, carried on every message, so timestamps stay near real time, never go backwards, and always sort a reply after the message it answers.
A clock that goes backwards on one machine is worse: its timestamps repeat, and with a repeated sequence it issues an ID it already gave out. A generator must notice and react:
| Situation | Response |
|---|---|
| The clock moved back a few milliseconds | Wait until it passes the last timestamp used |
| It moved back further than you will wait (10 ms in the code) | Stop issuing IDs, alert, and take the server out of service |
| Restart after a crash, with no memory of the last timestamp | Every few seconds save a time slightly ahead of the clock; after a restart, issue nothing until the clock passes it |
| Preventing jumps | Configure NTP to slew (change the clock speed gradually) instead of stepping it |
Every row has one aim: a generator never reuses a timestamp, even if it must stop.
Sorted IDs and write hot spots
In one database, a time-ordered key is a gift: every insert lands at the right-hand edge of the index. Now split the transactions table across nodes by key range, as many distributed SQL databases and wide-column stores do; the databases chapter called this range partitioning. Every new ID is larger than all before it, so every insert lands in the last range, on one node, while the others idle.
Splitting the hot range does not help for long, because the new upper half takes all the writes again. Write throughput stops at what one node absorbs, however many nodes you add. The same goes for UUIDv7, ULID and any key that starts with a timestamp. Three fixes spread the writes, each at a price:
| Fix | How it works | What it costs |
|---|---|---|
| Bucket prefix | Store the key as (hash(id) mod 16, id), so inserts spread over 16 ranges | A time-range read becomes 16 scans, merged |
| Owner first, time second | Partition by merchant_id; keep the time-ordered ID as the sort key inside | "Newest across all merchants" needs a secondary index or a query to every partition |
| Hash partitioning | Let the store place rows by a hash of the key | No cheap range scans by key |
Pick by the reads you need most: a merchant's statement favours owner-first, a global audit stream favours buckets.
The payments app chooses owner-first: a merchant's transactions live together and come back in time order with one range scan, which is exactly what a daily statement reads.
Putting the shard inside the ID
If transactions are partitioned by merchant anyway, why not let the ID say where it lives? A related layout puts a logical shard number inside the ID, for example 41 bits of time, 12 bits of shard and 10 bits of sequence.
Any service can then read the shard straight from an ID and route a request with no lookup table, and IDs still sort by time within a shard. Pick the shard from the owner, such as a hash of the merchant, so one merchant's rows live together. Let the database that owns the shard generate the sequence, so two payment servers never race for the same number; 10 bits give 1,024 IDs per millisecond per shard.
The catch is permanence: an ID can never change, so the shard number inside it is fixed forever. Create far more logical shards than machines, so data can move without changing a single ID.
Create 4,096 logical shards and map them onto, say, 8 physical databases, 512 each. Moving a logical shard to a new machine changes the map, not the IDs.
The cost is fewer bits elsewhere: 12 bits of shard leave 10 for the sequence and none for a machine id, so this layout only works where one owner per shard creates IDs. Few designs need it; the plain layout with an owner-first key is usually enough.
Other traps
A few more ways time-based IDs fail, each easy to prevent once you know it:
- Running out of time bits. 41 bits of milliseconds last about 69 years from the epoch. Pick a recent epoch and write it down; an ID scheme often outlives the team that designed it.
- Running out of sequence. 4,096 IDs per millisecond per generator is plenty for most services. If one process needs more, give it two machine ids, or move bits from the machine field to the sequence.
- IDs in JavaScript. JavaScript numbers are exact only up to 2^53, about 9 × 10^15, and these IDs pass that about 25 days after the epoch. A browser that parses one as a JSON number silently rounds it to a different ID.
- Information in the ID. A time-based ID reveals when a payment was made, and the machine bits hint at fleet size. Usually harmless; use a random public ID where it matters.
In JSON, write "id": "100663296000028675", not a bare number. The rounding bug hides in testing, because small test IDs fit within 2^53 and only real ones break.
Unit-test the generator with a fake clock: many IDs in one millisecond, sequence overflow, a clock that steps back 5 ms and one that steps back 5 seconds. These rarely happen on a laptop and eventually happen in production.
Most ID bugs are limits nobody wrote down: bits, epochs and number types. The last part leaves clocks behind and returns to counters, used carefully.
Part 4 · Ranges, Strict Order and Choosing
Blocks from a central service
A counter was the problem at the tea stall only because every shard had its own. Keep one counter, but stop asking it for every ID: a small ID service hands out blocks of numbers, say 10,000 at a time. Each payment server assigns IDs from its block in memory and asks for another when it runs low.
-- one row per sequence, for example 'txn' UPDATE id_blocks SET next_start = next_start + 10000 WHERE name = 'txn' RETURNING next_start - 10000 AS block_start;
PostgreSQL-style SQL. One atomic update per block; the caller owns block_start to block_start + 9,999.
If a server uses 2,000 IDs a second, a block of 10,000 lasts 5 seconds, so the ID service sees one request per server every 5 seconds instead of 2,000 a second. Fetch the next block in the background and keep a spare in hand, so a few seconds of ID-service downtime go unnoticed.
Block size is the trade. A block of 1,000,000 would ride out about 8 minutes of outage, but a crashed server throws away up to a million numbers, and IDs from different servers drift minutes apart in time. Bigger blocks buy outage cover with bigger gaps and looser order.
Three properties follow from the design:
- Compact and mostly increasing: small integers that grow over time, though servers working through different blocks interleave.
- Gaps are normal: a crashed server loses the rest of its block. IDs stay unique, not continuous.
- One dependency: the counter row must be highly available, for example in a consensus-backed store using compare-and-set, but it is called rarely.
When order must be strict
Everything so far gives IDs that are unique and roughly ordered. A few needs go further, and each has its own price:
| Need | Example | What it takes |
|---|---|---|
| Gap-free, strictly increasing numbers | A merchant's invoice numbers | One counter row updated in the same transaction as the invoice |
| One agreed order of events | Entries in a wallet's ledger, applied in the same order by every replica | A single sequencer, such as a partition leader, that stamps each event with the next number |
| Knowing which event caused which | A refund must sort after its payment | Logical clocks: vector clocks from the key-value store chapter, or hybrid logical clocks |
Each row costs more agreement than the one above it needs; take only the guarantee the record demands.
Invoices are the common case. India's GST rules, for example, ask for invoice serial numbers that are consecutive and unique within a financial year, and they allow more than one series. The counter row does it:
BEGIN; UPDATE invoice_counters SET last_no = last_no + 1 WHERE series = 'shop-17/2026-27' RETURNING last_no; -- say 4812 INSERT INTO invoices (series, invoice_no, total) VALUES ('shop-17/2026-27', 4812, 1499.00); COMMIT; -- a ROLLBACK undoes the +1 too
Plain auto-increment is not gap-free: a rolled-back insert still uses up its number. The counter row rolls back with the invoice. The price is throughput: the row stays locked until commit, so at about 5 ms per transaction one series manages about 200 invoices a second. Strict order is one writer at a time, so split the work into independent series, such as one per shop per financial year.
Choosing a scheme
With every option on the table, pick by the property the product cares about most. Here they are side by side:
| Approach | Size | Time order | Coordination | Main risk |
|---|---|---|---|---|
| Auto-increment | 64 bits | Yes | Every ID from one database | Bottleneck; leaks volume |
| Random UUID (v4) | 128 bits | No | None | Size; scattered index inserts |
| UUIDv7 or ULID | 128 bits | Roughly | None | Size; hot spots if range-partitioned |
| Snowflake-style | 64 bits | Roughly | A machine id per process | Clocks stepping back; hot spots |
| Blocks from a service | 64 bits | Loosely | One call per block | Gaps; the service's availability |
| Counter row | 64 bits | Strict, no gaps | Every ID through one row | One write at a time per series |
Size64 bits
Time orderYes
CoordinationEvery ID from one database
Main riskBottleneck; leaks volume
Size128 bits
Time orderNo
CoordinationNone
Main riskSize; scattered index inserts
Size128 bits
Time orderRoughly
CoordinationNone
Main riskSize; hot spots if range-partitioned
Size64 bits
Time orderRoughly
CoordinationA machine id per process
Main riskClocks stepping back; hot spots
Size64 bits
Time orderLoosely
CoordinationOne call per block
Main riskGaps; the service's availability
Size64 bits
Time orderStrict, no gaps
CoordinationEvery ID through one row
Main riskOne write at a time per series
Unique is cheap and local; ordered is expensive, because order needs agreement, either one central point or the consensus from the coordination chapter.
For a large service like the payments app, a sensible default is Snowflake-style IDs inside, sent to clients as strings, plus a random public ID wherever a link must not be guessable, and a counter row only for invoices. Switch to UUIDv7 when 128 bits are affordable and you want no machine ids at all; choose UUIDv4 when IDs are made on phones or merged from many sources.
Random UUIDs come from every language's standard library, and UUIDv7 and ULID libraries give sortable 128-bit IDs with no setup. Many open-source Snowflake-style generators follow the layout in this chapter. Databases offer sequences: AUTO_INCREMENT in MySQL, IDENTITY or SERIAL in PostgreSQL.
In the interview
ID generation usually comes up inside a bigger design, such as payments, chat or a link shortener, so answer in two or three sentences and move on.
- Say
- "Payment IDs must be unique across 50 servers and sort by time, so 64 bits: 41 of milliseconds, 10 of machine id, 12 of sequence. Half a UUID's size."
- Say
- "Each server leases its machine id at startup: one call per process, none per ID, so a 20-second registry outage stops nothing."
- Say
- "Transactions sit in a range-partitioned store, so I key them by merchant first: inserts spread out, and a statement is one range scan."
- Avoid
- "Use the current timestamp as the ID." At 2,000 payments a second, two land in the same millisecond all the time.
- Avoid
- "Use auto-increment" after sharding. Four shards each count on their own and hand out the same IDs.
Wrap-up
| Term | Meaning |
|---|---|
| UUIDv4 | A random 128-bit ID: no coordination, no time order |
| UUIDv7, ULID | 128-bit IDs that start with a millisecond timestamp |
| Snowflake-style ID | 64 bits: timestamp, machine id and per-millisecond sequence |
| Custom epoch | The zero point you count milliseconds from |
| Machine id | A number unique to each running generator, usually leased |
| Hot spot | One node taking most writes because new keys sort together |
| Sequencer | One place that stamps events with the next number |
| Hybrid logical clock | Physical time plus a counter: never goes back, respects cause and effect |
Next: Monitoring: Metrics, Logs, Traces and Alerts. Once a system runs on many machines, you need to know it is healthy before your users tell you it is not.
Part 5 · Check yourself
Quick check
Q1Why can't Snowflake-style IDs tell you which of two payments, made on different servers a millisecond apart, happened first?
Think it through, then check.
- Each ID's time comes from its own server's clock, and clocks differ by a few milliseconds, so the later payment can get the smaller ID.
- For a true order, route both through one sequencer, such as the ledger's partition leader; for cause and effect, use a hybrid logical clock.
Q2"We switched transactions from UUIDv4 to Snowflake-style IDs and write throughput fell." The store splits tables into key ranges across 12 nodes, and one node sits at 100% CPU. Why, and what are two fixes?
Think it through, then check.
- Snowflake-style IDs rise over time, so every insert lands in the last key range, on one node; random UUIDs had spread them over all 12.
- Fixes: key by merchant first with the ID as sort key, prefix a hash bucket such as hash(id) mod 16, or use hash partitioning.
Q3A team wants IDs that last 200 years from their epoch and expects at most 128 generators. How would you split the 63 usable bits, and how many IDs per millisecond does each generator get?
Think it through, then check.
- Time: 2^42 ms is about 139 years, too short; 2^43 ms is about 279 years, so 43 bits.
- Machine id: 128 generators need 7 bits. Sequence: 63 − 43 − 7 = 13 bits, so 8,192 IDs per millisecond.
Q4A parcel service needs 20,000 IDs a second from 300 containers that start and stop every few minutes. Snowflake-style IDs with leased machine ids, or blocks from an ID service? What is the trade?
Think it through, then check.
- Snowflake-style: no service on the hot path and IDs sort by time, but churn burns machine ids that must wait out old leases.
- Blocks: one call per container per block, so churn is harmless; the cost is gaps when containers die and a dependency on the ID service.
- With this much churn and no need for time order, blocks fit better.
Q5From the abstractions chapter: the app's "pay" call times out and the app retries. How can an ID stop the payment being made twice, and who should create it?
Think it through, then check.
- The phone creates the payment ID before the first attempt and sends the same ID on every retry.
- The server stores it under a unique constraint, so a retry finds the existing payment and returns it.
- Phone-made IDs are usually UUIDs: a Snowflake-style ID needs a machine id per phone.