Design a Distributed Unique ID Generator
This page is one interview loop in three rounds. All three rounds design the same system. Each round opens with the interviewer raising the scope, and the design from the round before has to evolve to meet it.
| Round 1: Mid-level | Round 2: Senior | Round 3: Architect | |
|---|---|---|---|
| Story | IDs for one product's database | Every service in the company gets IDs from us | Global, runs for decades, and IDs appear in public URLs |
| Level (Amazon) | SDE II (L5) | Senior SDE (L6) | Principal (L7) |
| Traffic | ~2K IDs/s average, ~10K peak | 100K IDs/s average, 300K peak | ~670K IDs/s average, ~2M peak across 5 regions |
| Survives | Losing a worker | Losing an AZ, and a worker that freezes | Losing a region |
| Availability | 99.9% | 99.99% | 99.999% for issuing IDs |
| Reading time | ~35 min | ~40 min | ~45 min |
You can start at any round. Rounds 2 and 3 open with a "Where we left off" summary that catches you up.
Loop Opener: What Is a Unique ID?
You Already Use One: the Auto-Increment Column
You have written this table many times:
sqlCREATE TABLE orders ( id BIGINT AUTO_INCREMENT PRIMARY KEY, customer_id BIGINT NOT NULL, total_cents BIGINT NOT NULL );
Every insert gets the next number: 1, 2, 3, and so on. That number is unique, it is small (8 bytes), and it is sorted by insert order. All three properties come from one fact: one machine hands out every number. It keeps a counter, and nobody else touches it.
Where IDs like this show up:
| Thing | Example ID | Who reads it |
|---|---|---|
| Order number | 97301141913751557 | the database, support staff, the customer |
| Tweet or post | a 64-bit number in the URL | everyone |
| Chat message | a 64-bit number | the client, to sort messages |
| Primary key of any row | a BIGINT | the database index |
What Changes When Many Machines Hand Out IDs
Put the counter on two machines and let each count on its own. Both will hand out 42, and two orders now share one key. One of them may silently overwrite the other.
We have two ways out:
- Talk to each other on every ID. Ask one central counter, or agree by vote. Every ID now costs a network round trip, and if the counter is down, nobody can create anything.
- Make a collision impossible by construction. Build each ID from parts that no other machine can produce at the same moment, so no machine ever needs to ask anyone.
This loop is about the second way, and about everything that can quietly break it.
The Question the Whole Loop Answers
How do we guarantee that no two IDs are ever the same, without machines talking to each other on every request?
The answer gets sharper every round:
- Round 1: each machine owns a unique machine number, and the ID is built from time, machine number and a counter.
- Round 2: we prove the machine number stays unique even when a machine freezes, loses its lease, or has the wrong time.
- Round 3: regions, decades, public exposure, and whether we should run this service at all.
Round 1 Β· Mid-level Β· "IDs for One Product's Database"
~35 min Β· SDE II (L5) Β· 1 region, 3 AZs Β· ~10K IDs/s peak Β· 99.9%
R1.1 Establish Design Scope
The interviewer says: "Design a service that generates unique IDs." Before we draw anything, we ask questions, and we say out loud what each answer changes.
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| Numeric or string? | Numeric. The IDs are primary keys in a MySQL orders database. | The ID must fit a 64-bit BIGINT. That rules out anything 128-bit. |
| Must IDs be sorted? | Roughly by creation time. The team pages through recent orders by ID. | We need time in the ID, near the front, so a bigger ID usually means a newer one. |
| Strictly increasing, with no gaps? | No. Gaps are fine, and two IDs made in the same instant can come out in either order. | We don't need one central counter. This one answer is what makes a fast, coordination-free design possible. |
| How many per second? | About 2,000 on average, about 10,000 at peak. Orders, order lines, payments and shipment events all need IDs. | Small. Throughput won't be the hard part. |
| Can the ID reveal anything, like the time it was made? | Not a concern yet. | We may put a timestamp in the ID. |
| How long must it work? | Decades. | Whatever holds the time must not run out for a very long time. |
Out of scope for this round:
- Gapless sequences ("invoice 1001 must follow invoice 1000"). They need one central counter, and nobody asked for it.
- Generating IDs in more than one region. The product runs in one AWS region.
- IDs shown to the public. Order IDs stay inside the company for now.
The interviewer will widen this scope later. Write your out-of-scope list where you can see it: in a multi-round loop, some of it comes back.
R1.2 Functional Requirements, Derived Step by Step
We read the problem one phrase at a time and turn each phrase into a requirement:
| Phrase from the problem | Requirement |
|---|---|
| "Every order needs an ID" | generate() returns one new ID |
| "Unique" | No two calls ever return the same ID: not on two machines, not before and after a restart, not in ten years |
| "Stored as a primary key" | The ID fits in 64 bits, as a positive signed integer |
| "The team pages through recent orders" | IDs are roughly ordered by creation time |
Not yet: asking for many IDs in one call, reading the creation time back out of an ID, and serving many calling teams. The interviewer may bring these back.
Why Sorted IDs Matter to a Database
A primary key in MySQL's InnoDB engine is a B-tree: a tree of fixed-size pages (16 KB by default) with keys kept in sorted order. Where a new key lands depends on its value.
- Random keys land on a random page. That page must be read into memory if it isn't there already, and when it is full it splits in two. Across a big table, almost every insert touches a different page, so the cache holds little that is useful. MySQL's documentation puts the result at pages anywhere from 1/2 to 15/16 full.
- Time-ordered keys always land at the right-hand edge of the tree. The same few pages stay hot in memory, and pages end up about 15/16 full.
textRandom keys Time-ordered keys [ 12 | 40 | 77 ] β insert 51 here [ ... | 97 | 98 ] β every insert goes [ 03 | 29 | 88 ] β insert 15 here to the last page [ 55 | 61 | 90 ] β insert 58 here every page is touched, many split one hot page, few splits
This is why "roughly sorted" is a real requirement and not a nice-to-have. The database that stores our IDs pays for random keys on every insert.
R1.3 Non-Functional Requirements: the Questions
Numbers come in R1.7. For now, the questions and why each matters:
- Availability. Every write in the product needs an ID first. If we are down, nobody can place an order.
- Latency. We sit on the write path of every request, so our time is added to every order.
- Uniqueness. This is the one promise we can never break. A duplicate ID can make one customer's order overwrite another's, silently.
- Scalability. If demand grows, adding generators should add capacity, without the generators having to talk to each other.
R1.4 The API
One endpoint, internal only:
httpPOST /v1/ids HTTP/1.1 Host: idgen.internal Content-Type: application/json {}
httpHTTP/1.1 200 OK Content-Type: application/json { "id": "97301141913751557", "id_num": 97301141913751557 }
We return the ID twice: id as a string, and id_num as a number for callers whose JSON parser keeps 64-bit integers. The reason fits in one line: JavaScript stores every number as a 64-bit float, which is only exact up to , and our IDs are bigger. Step 1.6 comes back to this.
| Status | When |
|---|---|
200 OK | An ID was made |
429 Too Many Requests | A caller is over its rate limit (with Retry-After) |
503 Service Unavailable | This worker can't safely make IDs right now (its clock went backwards, or it has no machine number); try another worker |
Recap
- One call, one 64-bit ID, returned as a string and as a number.
- IDs must be unique forever, roughly time-sorted, and fit a
BIGINT. - About 10,000 per second at peak; gaps are fine.
- We sit on every write, so we must be fast and always up.
- No coordination on the request path, if we can help it.
Let's build it, starting with the simplest thing that works.
R1.5 Design Evolution: From One Counter to Snowflake
Every step below follows the same pattern: a problem, your turn to think, the answer, and what the answer costs us. The cost is always the next problem.
Step 1.0: The Baseline
The database's own auto-increment column, or one small service with a counter in one database.
Synthesizing vector architecture diagram...
What's good about it: IDs are unique, 8 bytes, and strictly increasing. For a single database, this is the right answer and you should say so.
What it costs us: every ID comes from one machine. If it is down, nothing gets an ID. And when other databases or services need IDs from the same sequence, each ID is a network round trip to that one machine.
Step 1.1: The Counter Is a Bottleneck and a Single Point of Failure
The problem: the product now writes to several databases, and all of them need IDs from one sequence. The one counter database is a single point of failure, and every ID costs a round trip to it. What would you do? How do we hand out unique IDs without one machine at the center?
Primitive: Distributed Unique ID Generators
Step 1.2: 64 Bits, and Sorted by Time
The problem: the ID must fit in 64 bits and sort by creation time, and machines still can't talk to each other on each request. What would you do? What goes into the 64 bits?
The bit budget, proved. Each field's size sets a limit we must live with.
We pick the custom epoch 2026-01-01T00:00:00Z, which is 1767225600000 ms in Unix time. Why not the Unix epoch of 1970? Because 41 bits of milliseconds counted from 1970 run out in 2039: we would throw away 56 of our 69.7 years on a period before the service existed. Counting from our launch, the last timestamp is ms after the epoch, which is 2095-09-07T15:47:35.551Z.
textbit 63 62 βββββββββββββββββββββββββββ 22 21 ββββββββ 12 11 ββββββββββ 0 βββββββ¬βββββββββββββββββββββββββββββββββββ¬ββββββββββββββββ¬βββββββββββββββββ β 0 β timestamp: ms since 2026-01-01 β machine (10) β sequence (12) β β (1) β (41 bits, lasts ~69.7 years) β 0 β¦ 1,023 β 0 β¦ 4,095 β βββββββ΄βββββββββββββββββββββββββββββββββββ΄ββββββββββββββββ΄βββββββββββββββββ id = (timestamp << 22) | (machine << 12) | sequence
A worked example. An order created at 2026-09-26T12:00:00.000Z on machine 37, the sixth ID in that millisecond (sequence 5):
texttimestamp = 1790424000000 β 1767225600000 = 23198400000 ms since our epoch id = (23198400000 << 22) | (37 << 12) | 5 = 97301141913751557 binary = 0 | 00000010101100110101110110111111000000000 | 0000100101 | 000000000101 sign timestamp (41) machine=37 sequence=5
Twitter's original Snowflake used the same 41 + 10 + 12 split, with its own epoch in November 2010. Many systems copy it with their own epoch, Discord's for example starts in 2015.
Primitive: Distributed Unique ID Generators
Step 1.3: More Than One ID in the Same Millisecond
The problem: a burst of 30 orders arrives on one machine within one millisecond. The timestamp and machine fields are identical for all 30. What would you do?
Step 1.4: Two Workers Got the Same Machine Number
The problem: we run three generator workers. Autoscaling replaced one, and the new one started with machine number 1, the same as a worker that was still running. For the next hour, both made the same IDs whenever their sequences lined up in the same millisecond. What would you do? How does a worker get a machine number that nobody else is using?
The lease table:
| Attribute | Type | Example | Meaning |
|---|---|---|---|
machine_id (partition key) | Number | 37 | The machine number, 0 to 1,023 |
holder | String | i-0a1b2c3d4e5f67890 | The EC2 instance that holds the lease |
lease_expiry | Number | 1790424030000 | Unix time in ms when the lease ends, written by the holder |
The claim, as a DynamoDB UpdateItem request:
json{ "TableName": "idgen-leases", "Key": { "machine_id": { "N": "37" } }, "UpdateExpression": "SET holder = :me, lease_expiry = :expiry", "ConditionExpression": "attribute_not_exists(machine_id) OR lease_expiry < :now", "ExpressionAttributeValues": { ":me": { "S": "i-0a1b2c3d4e5f67890" }, ":now": { "N": "1790424000000" }, ":expiry": { "N": "1790424030000" } } }
If the condition fails, DynamoDB returns ConditionalCheckFailedException and changes nothing; the worker tries the next number. Note that :now comes from the worker's own clock: DynamoDB conditions have no "current time" function. Round 2 cares about that detail.
Primitive: Distributed Locks & Leases Β· Drill: Distributed lock fencing token
Step 1.5: The Clock Went Backwards
The problem: a worker's clock was 40 ms fast. The time daemon noticed and set it back 40 ms. For the next 40 ms, the worker produced timestamps it had already used, with sequences starting from 0 again: exact duplicates of IDs it made a moment ago. What would you do?
Synthesizing vector architecture diagram...
The clock is read on every ID. Only one branch refuses to answer, and it's the one where answering could repeat an ID.
Go deeper: how chrony can step, and how to stop it. Verified against the chrony documentation and AWS's EC2 guide.
text# /etc/chrony.conf on each worker server 169.254.169.123 prefer iburst minpoll 4 maxpoll 4 makestep 1.0 3
- The
serverline is the one AWS documents for the Amazon Time Sync Service;minpoll 4 maxpoll 4polls every 16 seconds. Amazon Linux 2023 is already set up to use it. makestep 1.0 3allows stepping, it does not prevent it: chrony steps the clock if it is off by more than 1.0 second, but only during its first 3 clock updates after it starts. After that it only slews (unless someone runschronyc makestepby hand). Without anymakestepline, chrony normally slews and doesn't step on its own.- Before a worker takes traffic, its start-up script runs
chronyc waitsync, which waits until chrony reports the clock is synchronized. So any start-up step happens before the first ID. - Slewing is fast enough: by default chrony may slew up to 83,333 ppm (1/12, set by
maxslewrate), so at that maximum rate a 40 ms error is gone in under half a second of slower running, with no backwards step. - The Amazon Time Sync Service smears leap seconds over time instead of inserting a 61st second, so the clock never repeats a second at a leap second.
Drill: Snowflake clock backward skew
Step 1.6: The Web App Shows the Wrong Order
The problem: the orders web page shows the wrong order when you click an order. Two different orders display the same ID. The backend logs are correct. What would you do?
Round 1 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.0 | (baseline) | One database counter | Single point of failure; a round trip per ID |
| 1.1 | Central counter is a bottleneck | Local random UUIDv4 (for now) | 128 bits; random keys hurt the B-tree |
| 1.2 | 64 bits and sorted | Snowflake layout: 1 + 41 + 10 + 12, custom epoch 2026 | Machine numbers must be unique; clocks matter |
| 1.3 | Many IDs in one millisecond | 12-bit sequence; wait for the next ms when full | Up to 1 ms wait above 4,096 IDs/ms |
| 1.4 | Duplicate machine numbers | Leased machine numbers in DynamoDB (conditional claim, 30 s lease, renew every 10 s) | New dependency; a lease can expire under a live worker |
| 1.5 | Clock went backwards | Remember last_ms; wait β€ 5 ms, refuse beyond; Amazon Time Sync + chrony slewing | A worker may refuse for a while; order only as good as clock sync |
| 1.6 | JavaScript mangles IDs | Send IDs as strings | Two representations |
The open cost from step 1.4, a lease can expire while its worker is still running, is where Round 2 begins.
R1.6 Architecture v1
Now the concepts get AWS names.
Synthesizing vector architecture diagram...
Follow the solid arrows: a request goes from a caller through the load balancer to one worker, and the worker answers from memory. Nothing on that path talks to anything else. The dotted arrows are off the request path: the lease table is touched once at start-up and then every 10 seconds, and the time service is polled by chrony in the background.
The pieces:
- Workers: three EC2 instances in an Auto Scaling group, one per AZ. Each is a
c7g.medium(1 Graviton vCPU, 2 GiB). The worker is one small process holding four values in memory: its machine number, its lease expiry,last_msandsequence. - Internal Network Load Balancer (NLB): a layer-4 load balancer, which passes TCP connections through without parsing HTTP. It checks each worker's
/healthz, which returns503when the worker is refusing to make IDs. - Lease table: DynamoDB, on-demand capacity. One row per machine number.
- Time: chrony on each instance, synced to the Amazon Time Sync Service.
Trace 1: one ID request.
Synthesizing vector architecture diagram...
The only network hops are the caller to the NLB to the worker and back. The ID is a few integer operations in memory.
Trace 2: a worker starts and claims its machine number.
Synthesizing vector architecture diagram...
The worker makes no ID until it has a synced clock and a machine number. It starts its search at a random number, so workers starting together don't all fight over slot 0. With only three workers, the first or second try almost always succeeds. (The trace shows the unlucky case: it happened to start on the slot of the worker it is replacing, whose lease hasn't expired yet.)
R1.7 Numbers and Bit Budget
Traffic against capacity.
| Quantity | Value |
|---|---|
| Peak demand | 10,000 IDs/s = 10 IDs per millisecond |
| One machine's sequence ceiling | 4,096 IDs/ms = 4,096,000 IDs/s |
| Ceiling Γ· peak | about 410 |
One worker's sequence could handle 410 times our peak. The real limit on a worker is how many HTTP requests one vCPU can serve. We plan on 20,000 requests per second per c7g.medium. That is a planning assumption, not a measured fact: we confirm it with a load test before launch.
So why three workers? Not for throughput. For availability. One worker per AZ means losing a worker, or a whole AZ, leaves two workers with 40,000 requests/s of capacity against a 10,000 peak. It also lets us deploy one worker at a time.
Availability budget. 99.9% over a 30.4-day month allows minutes of downtime. With three independent workers behind an NLB, a single worker failure costs us nothing; the budget is really for mistakes, such as a bad deploy or the lease table being misconfigured.
Latency. Making the ID takes well under a microsecond. P99 under 5 ms is almost all network: caller to NLB to worker and back inside one region.
The timestamp's lifespan (from step 1.2): 69.7 years from 2026-01-01, ending 2095-09-07.
IDs per year and what they cost downstream. We store nothing. The callers store every ID, as a key.
| Key size | Bytes per year, per copy of the key |
|---|---|
8-byte BIGINT (Snowflake) | GB |
| 16-byte UUID | TB |
"Per copy" matters: an ID is stored once in the primary key, again in every secondary index, and again in every table that refers to it.
Monthly cost (us-east-1 on-demand prices, 730 hours a month):
| Item | Math | Monthly |
|---|---|---|
3 Γ c7g.medium | 3 Γ $0.0363/h Γ 730 h | β $79 |
| NLB, fixed | $0.0225/h Γ 730 h | β $16 |
| NLB, capacity units | ~200 bytes per request and reply (an assumption) Γ 2,000/s β 1.4 GB/h β 1.4 units Γ $0.006/h Γ 730 h | β $6 |
| DynamoDB lease renewals | 3 workers Γ 1 write per 10 s β 789K writes/month Γ $0.625 per million | β $0.50 |
| CloudWatch metrics and alarms | a few dozen custom metrics and alarms | β $10 |
| Total | β $110/month |
Say it plainly in the interview: this service is tiny. The hard part is not throughput or cost. It is keeping one promise, never twice, while machines crash and clocks lie.
R1.8 Trade-Offs
| Scheme | Size | Sorted by time? | Coordination per ID | Throughput | What it reveals | Operational burden |
|---|---|---|---|---|---|---|
| Auto-increment (one DB) | 8 B | Strictly | Every ID goes to one DB | One DB's write rate | Exact count | One DB is a single point of failure |
Multi-master step = k | 8 B | Per server only | Every ID is a DB write | Sum of the DBs' write rates | Count, and which server | Changing k to add a server is risky |
| Ticket server (Flickr) | 8 B | Strictly with one server; only roughly with Flickr's two odd/even servers, or by block | A round trip per ID (or per block) | One DB's write rate, or more with blocks | Exact count | A database to run just for numbers |
| UUIDv4 | 16 B | No, random | None | Very high | Nothing | None |
| UUIDv7 (RFC 9562) | 16 B | Yes, by ms | None | Very high | Creation time | None |
| ULID | 16 B | Yes, by ms | None | Very high | Creation time | None |
| Snowflake | 8 B | Roughly (within clock skew) | None per ID; a lease per worker | 4,096 IDs/ms per worker | Creation time, machine, per-ms count | Machine numbers and clocks |
UUIDv7 (RFC 9562, 2024) puts a 48-bit Unix timestamp in milliseconds at the front of a 128-bit UUID and fills most of the rest with random bits. ULID is an older design with the same idea: 48 bits of milliseconds and 80 random bits, usually written as 26 characters. Both sort by time and need no coordination at all. Their only problem here is size: 16 bytes don't fit a BIGINT.
Why Snowflake fits this scope: it is the only option that is 64-bit, time-sorted and free of per-ID coordination. We pay for that with two things to run: the machine-number leases and clock discipline. At this scope, that price is small.
R1.9 Failure Modes
| Failure | What you'd see | How the design responds |
|---|---|---|
| A worker crashes | NLB health check fails; its connections reset; callers reconnect to the other two workers. | The crashed worker stops renewing. Its lease expires within 30 s, and its machine number can be claimed again by the replacement (or any new worker). |
| The sequence saturates | More than 4,096 IDs in one millisecond on one worker. At our peak of 10 per ms, only a bug or a runaway caller does this. | The worker waits for the next millisecond: at most 1 ms added to those requests. We alarm on how often it happens. |
| The clock steps back a little | A few milliseconds of slower responses on one worker. | Up to 5 ms: the worker waits for the clock to catch up. More than 5 ms: it refuses, fails its health check and alarms; the other two workers carry the load. |
| The lease table is briefly unavailable | Renewals fail; lease-renewal-error alarm. | Running workers keep issuing IDs: each still holds a lease that is valid for up to 30 s after its last renewal. New workers can't start until the table is back. A long outage is Round 2's problem. |
R1.10 Pillar Check
| Pillar | What Round 1 covers |
|---|---|
| Reliability | No coordination on the request path; three workers, one per AZ; leased machine numbers so a crashed worker's number is reclaimed; workers refuse rather than risk a duplicate REL 10 Β· REL 11 |
| Performance Efficiency | IDs built in memory in well under a microsecond; latency is the network hop; a layer-4 NLB with kept-alive connections PERF 4 |
| Security | Internal-only endpoint in private subnets; workers' IAM role may update only the lease table; TLS between callers and workers SEC 3 Β· SEC 9 |
| Cost Optimization | About $110/month; three workers sized for availability, not throughput, and we say why COST 6 |
| Operational Excellence | Light this round: alarms on clock offset (chrony), clock-backward refusals, sequence saturation and lease-renewal errors OPS 8 |
| Sustainability | Light this round: small Graviton instances; 8-byte keys keep callers' indexes half the size of UUID keys SUS 5 |
R1.11 Round 1 Rubric and Follow-Ups
What a strong mid-level (L5) answer shows
- Asks whether IDs must be strictly ordered and whether gaps are allowed, and explains that "roughly ordered, gaps OK" is what makes a coordination-free design possible.
- Explains why sorted keys matter to a B-tree.
- Walks from one counter to the Snowflake layout, and says why each rejected option fails a requirement.
- Proves the bit budget: 69.7 years, 1,024 machines, 4,096 IDs per ms.
- Knows machine numbers must be assigned dynamically, and that the clock can go backwards.
- Sends IDs as strings to JavaScript clients, and knows why.
Follow-up questions
-
"Why a custom epoch instead of the Unix epoch?" Answer: 41 bits of milliseconds last 69.7 years from whatever start we choose. From 1970 they would run out in 2039, and the 56 years before our launch would be wasted. From 2026-01-01 they last until 2095-09-07.
-
"Why not make the sequence bigger and the machine field smaller? We only have three workers." Answer: we could: 41 + 5 + 17 gives 32 machines and 131,072 IDs per ms. But we don't need more per-machine throughput (we use 10 of 4,096 per ms), and machine numbers get used faster than you'd think: every deploy starts new workers before the old ones stop, and crashed workers hold their numbers for 30 s. Round 2 will need many more machine numbers, not fewer.
-
"A worker restarts. Could it repeat IDs it made before the restart?" Answer: its memory, including
last_ms, is gone, so step 1.5's check can't help. What protects it is the lease table. The new process must claim a machine number again, and it can only claim a number whose lease has expired by its own clock. A healthy worker renews every 10 s, so every ID made under that number was made before the stored expiry, and every ID the new process makes comes after it. The one gap in this argument is a worker that keeps making IDs after its lease expired, for example because it froze and woke up. Round 2 closes that gap and turns this argument into a rule.
Interview gotchas from this round's wrong answers
| Gotcha | Why it's wrong |
|---|---|
| "Hard-code the worker ID in config" | Every copy of the config or image gets the same number; autoscaling makes copies constantly. |
| "Clocks don't go backwards" | Daemons, operators, VM restores and boot all set clocks back. The worker must check. |
| "Timestamp alone" or "timestamp + random bits" | Same-millisecond IDs collide; at 10 per ms with 22 random bits, about 39 duplicates an hour at peak. |
| "Send the ID as a JSON number" | JavaScript rounds anything above ; our IDs passed that 25 days after the epoch. |
| "Use the monotonic clock as the timestamp" | Its starting point is arbitrary per machine and per boot, so its values can't be compared across machines. |
Round 2 Β· Senior Β· "Every Service in the Company Needs IDs"
~40 min Β· Senior SDE (L6) Β· 1 region, 3 AZs Β· 300K IDs/s peak Β· 99.99% Β· zero duplicates, even when a worker freezes
R2.0 Where We Left Off
This is what the candidate says aloud in the first 60 seconds of Round 2. If you're starting here, it's everything you need from Round 1.
Round 1 in 60 seconds. "We built an ID service for one product's orders database: about 10,000 IDs a second at peak, one region, three AZs, 99.9% availability. IDs are 64-bit Snowflake IDs: 1 sign bit, 41 bits of milliseconds since a custom epoch of 2026-01-01, 10 bits of machine number and 12 bits of sequence. That gives 69.7 years, 1,024 machines, and 4,096 IDs per millisecond per machine. Each worker leases its machine number from a DynamoDB table with a conditional write, renews every 10 seconds on a 30-second lease, remembers its last timestamp, waits out a clock step back of up to 5 ms and refuses beyond that. Clocks sync to the Amazon Time Sync Service with chrony. IDs go to JavaScript as strings. Three small workers behind an internal NLB, about $110 a month. Three costs are still open: a worker can outlive its lease, every ID costs a network hop, and we only have 1,024 machine numbers."
The layout
textβββββββ¬βββββββββββββββββββββββββββββββββββ¬ββββββββββββββββ¬βββββββββββββββββ β 0 β timestamp: ms since 2026-01-01 β machine (10) β sequence (12) β β (1) β (41 bits, until 2095-09-07) β 0 β¦ 1,023 β 0 β¦ 4,095 β βββββββ΄βββββββββββββββββββββββββββββββββββ΄ββββββββββββββββ΄βββββββββββββββββ
Architecture v1, compact
textservices βββΊ internal NLB βββΊ 3 workers (c7g.medium), one per AZ β id = (ts << 22) | (machine << 12) | seq, all in memory β last_ms check: wait β€ 5 ms back, refuse beyond βββ off the request path: DynamoDB idgen-leases β claim if free or expired; renew every 10 s; 30 s lease βββ chrony βββ Amazon Time Sync (169.254.169.123)
Round 1 step summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 1.1 | Central counter is a bottleneck | (UUIDv4, rejected next) | 128 bits, random |
| 1.2 | 64 bits and sorted | 1 + 41 + 10 + 12, custom epoch | Unique machine numbers; clocks matter |
| 1.3 | Many IDs per ms | 12-bit sequence, wait when full | β€ 1 ms wait above 4,096/ms |
| 1.4 | Duplicate machine numbers | DynamoDB leases, conditional claim | A lease can expire under a live worker |
| 1.5 | Clock went backwards | last_ms check, chrony slewing | Refusals; order only as good as clocks |
| 1.6 | JavaScript mangles IDs | String IDs | Two representations |
Open costs: a worker can outlive its lease; every ID costs a network hop; 1,024 machine numbers.
R2.1 The Scope Raise
Interviewer: "Your service worked. Now every team in the company wants it. We're at 100,000 IDs a second on average and 300,000 at peak. The data ingestion pipelines want IDs in batches of up to 1,000. A few high-volume services say even one network hop per ID is too slow for them. Teams want to read the creation time out of an ID. You must survive losing a whole AZ. And one more thing: a duplicate ID is now a company-wide incident. Zero duplicates, even when a worker freezes."
A scope raise is not the end of scoping. Before we fix anything, we ask back, and we say what each answer changes, just as in R1.1.
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| Still one region? | Yes, one region, three AZs. | No region bits yet. Every machine number is still handed out from one lease table. |
Are the IDs still 64-bit BIGINT keys? | Yes. Hundreds of tables already use them. | The layout can't grow. Any extra machine numbers must come out of the same 63 bits. |
| What latency do callers need? | P99 under 1 ms for the service. Some callers want no network hop at all. | A hop per ID is too slow for them. We need batches, client buffers, or the generator inside the caller's process. |
| How many callers want to skip the hop, and how many processes do they run? | About six high-volume teams, a few hundred processes between them. | If each process needs a machine number, 1,024 numbers is getting tight. |
| Must IDs from different machines be strictly ordered? | Teams sort by ID and assume they are. | We must tell them they're not, and write down what we do promise. |
| "Freezes": how long a pause do we plan for? | We've seen 30β40 s garbage-collection pauses and VM migrations. | A worker can wake up after its lease expired. The lease rules must stay correct through that. |
| Must decoding be a network call? | No, a library is fine, but there should be one official answer. | We publish the layout. The client library decodes it locally, and an endpoint gives the official answer. |
Scope change
| Round 1 | Round 2 | |
|---|---|---|
| Callers | One product | Every service in the company |
| Traffic | ~2K average, ~10K peak IDs/s | 100K average, 300K peak IDs/s |
| Calls | One ID per call | + batches of up to 1,000; decode; per-caller quotas |
| Where IDs are made | Our workers only | + inside a few callers' processes (embedded library) |
| Survive | A worker | An AZ, and a worker that freezes |
| Availability | 99.9% (43.8 min/month) | 99.99% (4.4 min/month) |
| Latency | P99 < 5 ms | P99 < 1 ms, or no hop at all |
| Duplicates | Must not happen | Must be provably impossible, even through pauses |
The "Not yet" list from R1.2 is now mandatory: batches, decoding an ID, and many calling teams. The zero-duplicates-under-pauses requirement changes the design most.
R2.2 What Breaks in the Round 1 Design
| Round 1 choice | What breaks at the new scope |
|---|---|
| One network hop per ID | Callers minting hundreds of IDs per request, or with a sub-millisecond budget, can't afford it. |
| "Renew every 10 s, claim when expired" | A worker frozen for 40 s (a long garbage-collection pause, a VM being moved) can wake up after its lease was given to another worker, and both make IDs under the same machine number. |
| 1,024 machine numbers | Fine for a service fleet. If the generator runs inside callers' processes, thousands of processes each need a number. |
| One worker absorbs whatever lands on it | A hot caller on one long-lived connection pins one worker; a burst can saturate its sequence. |
| The lease table is "off the path" | With a 30 s lease, a lease table outage longer than about 25 s stops every worker. At 99.99%, its availability now matters to us. |
| "IDs are sorted" | Many teams now sort by ID across machines and expect a strict order. They won't get one. |
R2.3 New Requirements and API Additions
Batches
httpPOST /v1/ids:batch HTTP/1.1 Host: idgen.internal Content-Type: application/json { "count": 1000 }
httpHTTP/1.1 200 OK Content-Type: application/json { "ids": ["97301141913751557", "97301141913751558", "..."] }
count must be 1 to 1,000, or the answer is 400 Bad Request. A batch is made by one worker in one pass, so its IDs are in increasing order. A batch of 1,000 fits in one millisecond's sequence (4,096), unless other requests on the same worker used most of that millisecond, in which case it spills into the next.
Decode
httpGET /v1/ids/97301141913751557/decode HTTP/1.1 Host: idgen.internal
httpHTTP/1.1 200 OK Content-Type: application/json { "id": "97301141913751557", "created_at": "2026-09-26T12:00:00.000Z", "machine": 37, "sequence": 5, "layout": "v1" }
Decoding is just shifts and masks, so the client library does it locally and nobody needs a network call for it. We still publish the endpoint, for people and tools, and so there is one official answer. It becomes important if the layout ever changes (Round 3 changes it).
Caller identity and quotas. Every caller connects with mutual TLS using a certificate issued per service (by AWS Private CA), so each connection carries a known identity, checked once when the connection opens. Each caller has a quota in IDs per second. Over quota, the answer is 429 Too Many Requests with Retry-After.
The embedded library. For callers that can't pay a hop, we ship the generator as a library that runs inside their process. It must keep every promise a worker keeps:
- It leases its own machine number from the same lease table, with the same rules (step 2.2), under an IAM role allowed to touch only that table.
- Its host runs chrony against the Amazon Time Sync Service, and the library refuses to start until the clock is synchronized.
- It enforces the same clock and lease checks as the service, and publishes the same metrics.
- The owning team upgrades it within a fixed window when we release a fix.
R2.4 Design Evolution: Making "Never Twice" Hold Under Failure
Step 2.1: A Network Hop per ID Is Too Slow
The problem: an ingestion pipeline creates 200 records per incoming message and needs an ID for each. Two hundred round trips per message are too slow, even at a millisecond each. What would you do?
Step 2.2: A Worker Froze for 40 Seconds
The problem: worker A holds machine number 37 on Round 1's 30-second lease, with epoch 7. It freezes for 40 seconds: a long garbage-collection pause, or its VM is being moved. It sends no renewals, so its lease expires, and worker B claims number 37 (the row's epoch becomes 8). Then A wakes up with a queue of requests and carries on making IDs as number 37. Its heartbeat thread hasn't even run yet. What would you do? How do we make sure A and B never produce the same ID, even though for a while both believe they own number 37?
The lease table, with the epoch (the row just after B's claim)
| Attribute | Type | Example | Meaning |
|---|---|---|---|
machine_id (partition key) | Number | 37 | The machine number |
holder | String | i-0fedcba9876543210/pid-907/1790424129000 | Instance, process and start time, so a restarted process on the same instance counts as a new holder |
lease_expiry | Number | 1790424161000 | Unix ms; moves forward on every renewal, and down only when the holder releases the number (never below its last issued timestamp) |
lease_epoch | Number | 8 | +1 on every claim; renewals must match it (together with holder) |
The claim (worker B, clock reading 1790424131000, one second after A's expiry of 1790424130000):
json{ "TableName": "idgen-leases", "Key": { "machine_id": { "N": "37" } }, "UpdateExpression": "SET holder = :me, lease_expiry = :expiry, lease_epoch = if_not_exists(lease_epoch, :zero) + :one", "ConditionExpression": "attribute_not_exists(machine_id) OR lease_expiry < :now", "ExpressionAttributeValues": { ":me": { "S": "i-0fedcba9876543210/pid-907/1790424129000" }, ":now": { "N": "1790424131000" }, ":expiry": { "N": "1790424161000" }, ":zero": { "N": "0" }, ":one": { "N": "1" } }, "ReturnValues": "ALL_NEW" }
The renewal (worker A, just awake at clock reading 1790424145000, still believing it holds epoch 7):
json{ "TableName": "idgen-leases", "Key": { "machine_id": { "N": "37" } }, "UpdateExpression": "SET lease_expiry = :new_expiry", "ConditionExpression": "holder = :me AND lease_epoch = :my_epoch AND lease_expiry < :new_expiry", "ExpressionAttributeValues": { ":me": { "S": "i-0a1b2c3d4e5f67890/pid-4121/1790424000000" }, ":my_epoch": { "N": "7" }, ":new_expiry": { "N": "1790424175000" } } }
After B's claim, the row says holder B and epoch 8, so A's renewal fails with ConditionalCheckFailedException. A drops number 37 and, if it's healthy, claims a different free number. These examples use Round 1's 30-second lease, which is what we have at this point; step 2.5 lengthens it to 5 minutes, and nothing in this argument changes.
Why renewals check both the holder and the epoch. The holder name includes the process and its start time, so a process that crashed and restarted on the same instance is a new holder, not the old one. The epoch alone would catch a stale renewal in normal operation, but epochs can repeat if the table is ever restored from an old backup (step 3.2 deals with that). Requiring both to match means a stale holder's renewal can only succeed on a row that still names it, which is exactly the case where nobody else has the number.
Primitive: Distributed Locks & Leases Β· Drill: Distributed lock fencing token
Step 2.3: We Ran Out of Machine Numbers
The problem: twenty teams want the embedded library. Some run it in hundreds of containers each. Together they need several thousand machine numbers. We have 1,024. What would you do?
Go deeper: other real layouts make different choices. Instagram's IDs (generated inside PostgreSQL) use 41 bits of milliseconds, 13 bits of logical shard and 10 bits of sequence, favouring many shards. Sony's Sonyflake uses 39 bits of time in units of 10 ms (about 174 years), 16 bits of machine ID and 8 bits of sequence: 65,536 machines, but only 256 IDs per 10 ms per machine. Its newer version lets you choose the bit split and time unit.
Primitive: Distributed Unique ID Generators
Step 2.4: One Caller Is Hammering One Worker
The problem: the busiest pipeline opens a single long-lived connection. The NLB balances connections, not requests, so every request from that pipeline lands on one worker. That worker is at 90% CPU and saturating its sequence while the others idle. What would you do?
Primitive: Distributed Rate Limiting
Step 2.5: The Lease Table Is Down
The problem: DynamoDB in our region has trouble for three minutes. Renewals fail. With a 30-second lease, every worker hits its fence about 25 seconds after its last good renewal, and the whole company stops creating records. What would you do?
Step 2.6: A Team Sorted by ID and Got the Wrong Order
The problem: the payments team sorts events by ID and finds a "refund" before the "charge" it refunds. The charge got its ID from worker 3, the refund from worker 9 a fraction of a millisecond later. What would you do?
Primitive: Distributed Consensus: Raft & Paxos
Round 2 Step Summary
| Step | Problem | Component | What it costs us |
|---|---|---|---|
| 2.1 | A hop per ID is too slow | Batches (β€ 1,000), client buffer, embedded library | Buffered IDs age; more machine numbers |
| 2.2 | A frozen worker wakes after losing its lease | Timestamp fence: never issue ts β₯ lease_expiry β 5 s; new holder starts after the stored expiry; renewals checked on holder and epoch that only move expiry forward (release aside) | Brief refusals near lease end |
| 2.3 | Out of machine numbers | Bit-budget math; ranges 0β63 service, 64β1,023 embedded; approved callers only; release on shutdown | Reviews; a layout change later is a migration |
| 2.4 | One hot caller pins one worker | Several connections, recycled; batches; per-caller quotas; saturation alarm | More connections and numbers in use |
| 2.5 | Lease table outage stops everything | 5 min lease, 10 s renewal, 5 s margin; spare capacity so AZ loss needs no new workers | Dead numbers held up to 5 min; deploys pause |
| 2.6 | Cross-worker order is wrong | Documented k-sorting; per-entity versions or a single sequencer for strict order | Expectation setting |
R2.5 Architecture v2
Synthesizing vector architecture diagram...
Two paths share one lease table. Service callers reach the fleet through the NLB; the six approved callers generate IDs in their own processes. Both paths follow the same rules, so an ID's machine field is unique no matter which path made it.
Trace: a pause and a wake-up, with the fence marked. Times are wall-clock seconds, and the lease here is 30 s to keep the numbers short (the same argument holds for 5 minutes).
textwall-clock time (s) 100 105 110 120 125 130 131 145 β β β β β β β β worker A (epoch 7) renew β ID ββ freeze βββββββββββββββββββββββββββΊ wakes expiry=130 ts=105 (renewals at 110, 120 never sent) β ββ delivers the ID with ts=105 fence = 130 β 5 = 125 βββ β (105 < 125: fine, and B only β β makes ts > 130) βΌ ββ next request: ts=145 β₯ 125 β refuse worker B claim β ββ renewal "epoch 7" fails β drops #37 epoch 8 last_ms=131, makes ts β₯ 131
A's IDs all sit below 125; B's all sit above 130. The five-second gap between them is the margin. Nothing in this picture depends on A and B having the same time: each compares its own clock with the one number in the table.
R2.6 Numbers and Cost
Traffic and volume
| Quantity | Math | Value |
|---|---|---|
| Average | given | 100,000 IDs/s |
| Peak | 3 Γ average | 300,000 IDs/s = 300 per ms |
| IDs per year | 100,000 Γ 31,557,600 s | β 3.16 Γ 10ΒΉΒ² |
| Downstream key bytes, 8-byte IDs | 3.16 Γ 10ΒΉΒ² Γ 8 B | β 25.2 TB/year per copy of the key |
| Same with 16-byte UUIDs | 3.16 Γ 10ΒΉΒ² Γ 16 B | β 50.5 TB/year per copy |
We use the average for the yearly volume: a year at peak rate would overstate it three times.
Per-worker ceiling against demand. One machine's sequence allows 4,096,000 IDs/s, more than 13 times the whole company's peak. Again, requests are the limit, not IDs. We plan on 50,000 requests per second per c7g.large (2 vCPUs), an assumption we confirm by load test, and we assume the worst case of one ID per request (batches only make it easier).
Fleet size for AZ loss
Those 6 must still be there after losing an AZ, so the two surviving AZs must hold them. Four workers per AZ gives 12 in total. After losing an AZ: requests/s of capacity against a 300,000 peak, or 75% busy. Without the AZ loss, peak runs at . We add no workers during an AZ loss, which matters because start-up depends on the lease table (step 2.5).
Lease-table request rate
At $0.625 per million on-demand writes, that is about $35 a month. The lease table never sees a request per ID: the fence check is in memory.
Monthly cost (us-east-1, on-demand)
| Item | Math | Monthly |
|---|---|---|
12 Γ c7g.large | 12 Γ $0.0725/h Γ 730 h | β $635 |
| NLB, fixed | $0.0225/h Γ 730 h | β $16 |
| NLB, capacity units | ~200 B per request and reply Γ 100K/s average β 72 GB/h β 72 units Γ $0.006 Γ 730 h. An upper bound: it counts every ID as an NLB request, but embedded-library IDs never touch the NLB, and batches carry many IDs per request | β€ $315 |
| DynamoDB lease table | 55.8 M writes Γ $0.625/M | β $35 |
| CloudWatch metrics, alarms, logs | β $50 | |
| Total | β $1,050/month |
The whole fleet costs less per month than the extra storage a year of 16-byte keys would add to callers' databases. That comparison comes back in Round 3.
R2.7 Trade-Offs
| Choice | We chose | What we give up |
|---|---|---|
| Service vs embedded library | Service by default; embedded for approved high-volume callers | Service: a hop per call. Embedded: our correctness depends on code and clocks in processes we don't run, and every embedded process uses a machine number. |
| Lease table: DynamoDB vs ZooKeeper/etcd vs static assignment | DynamoDB conditional writes | ZooKeeper's ephemeral nodes or etcd leases would also work, but mean running a consensus cluster just for this, and they have the same paused-holder problem, so we'd still need the timestamp fence. Static assignment (a number per host in config) needs no dependency but breaks with autoscaling and copies. |
| Bit allocation | Keep 41 + 10 + 12; ration numbers | More machine bits cost per-machine rate or lifespan, and any change is a migration. |
| K-ordered vs strictly ordered | K-ordered, documented | Teams that need strict order carry their own per-entity versions or use a single sequencer. |
| Snowflake vs UUIDv7 at this scale | Snowflake | UUIDv7 would remove leases, clocks-as-a-service and machine numbers, but add about 25 TB a year of key bytes per copy of the key, and every existing BIGINT key column would need to change. |
R2.8 Failure Modes
| Failure | Trigger | What you'd see | How the design responds |
|---|---|---|---|
| A long pause | A 40 s garbage-collection pause or a VM migration | The worker stops answering; its renewals stop; after the pause, a burst of refusals from it | The timestamp fence refuses IDs past the lease; the stale renewal fails on the epoch; the worker claims a new number or exits. No duplicate is possible. |
| A big clock step backwards | An operator or tool sets the clock back by seconds | Clock-backward refusals from one worker; its health check fails | The last_ms check refuses; the NLB routes around it; the process restarts and claims a fresh number, starting after that number's stored expiry. |
| Losing an AZ | An AZ-wide failure | A third of the workers gone; connections to them reset | The 8 remaining workers carry peak at 75%. Their numbers stay leased; the lost workers' numbers expire after 5 minutes. The lease table (DynamoDB) is multi-AZ. |
| Lease table unavailable during a deploy | A deploy starts new workers while DynamoDB has errors | New workers can't claim numbers and never become healthy | The deploy only stops an old worker after its replacement is healthy, so the old ones keep serving. The pipeline pauses on the lease-error alarm. New workers start their slot search at a random number within their range, as in Round 1, so a big deploy doesn't have every worker fighting over the same slots. |
| Clients reconnecting at once | Workers restart, or an AZ comes back | A spike of new TLS connections and CPU on the workers | Client libraries reconnect with exponential backoff and full jitter (a random wait between 0 and the backoff), so reconnections spread out. |
| Connection exhaustion | A caller opens a new connection per ID | Handshake CPU climbs; latency rises from well under a millisecond to several | Libraries keep a fixed pool of long-lived connections; a caller without one hits its connection quota. |
Drill: WebSocket reconnect thundering herd
R2.9 Production Gotchas
| Gotcha | Symptom | Cause | Fix |
|---|---|---|---|
| Hard-coded worker IDs | Duplicate IDs appear after a scale-out | Every copy of the image started with the same number | Lease numbers from the table; the worker refuses to start without one |
Wall clock without a last_ms check | Duplicates right after a clock correction | The clock went back and the counter reset | Remember last_ms; wait for small steps, refuse big ones |
| Too many machine bits | Latency spikes during bursts; saturation alarms | A layout with a tiny sequence, for example 7 bits (128 IDs/ms) | Keep enough sequence for the burst each process sees; add machine bits only with the math |
| A new connection per ID | Latency of several milliseconds per ID, high CPU on TLS | An unpooled HTTP client, often behind a load balancer that terminates TLS per connection | Pooled, long-lived connections; batches; an NLB passing connections straight through |
| IDs sent to browsers as numbers | Two orders show the same ID in the UI | JavaScript rounds numbers above | String IDs in every JSON response |
R2.10 Pillar Check
| Pillar | What Round 2 adds |
|---|---|
| Reliability | AZ loss absorbed with no new workers (12 workers, 75% at peak after the loss); the timestamp fence makes duplicates impossible even through pauses; a 5 min lease rides out lease-table outages REL 10 Β· REL 11 Β· REL 5 |
| Performance Efficiency | Batches, client buffers and the embedded library remove the per-ID hop; several connections per caller spread load across workers PERF 1 Β· PERF 4 |
| Security | Mutual TLS with a certificate per calling service; per-caller quotas; the workers' and embedded libraries' IAM roles may only update the lease table SEC 2 Β· SEC 3 Β· SEC 9 |
| Cost Optimization | About $1,050/month, sized from the math; the real cost of IDs is key size in callers' databases, which 8-byte IDs halve COST 5 Β· COST 6 |
| Operational Excellence | Alarms, each with a first action (below); deploys replace a worker only after its replacement is healthy OPS 6 Β· OPS 8 |
| Sustainability | Light this round: Graviton; the fleet sized to demand with the AZ-loss headroom and no more; 8-byte keys save callers ~25 TB a year per key copy SUS 2 Β· SUS 5 |
Alarms and first actions
| Signal | Alarm | First action |
|---|---|---|
Clock offset (from chronyc tracking), per host, in either direction | > 1 ms for 5 min (warn); > 5 ms (page: the worker refuses and fails its health check at that point) | Check chrony's source and status on that host; replace the host if it doesn't recover |
| Clock-backward refusals | any | Find the host; check what set its clock (chrony log, operator action, VM event) |
| Lease renewal failures | 2 in a row on any holder (warn); on many holders (page) | Check DynamoDB errors and throttling, and the holder's IAM role; pause deploys |
| Fence refusals (lease near expiry) | any | Look for a pause (GC logs, instance events) or a renewal failure on that holder |
| Sequence saturation | > 1% of milliseconds on a worker for 5 min | Find the hot caller; move it to batches or more connections |
| Free machine numbers | < 20% left in a range | Find leaked numbers (crash loops, missing shutdown release) before approving new embedded callers |
R2.11 Round 2 Rubric and Follow-Ups
What a senior (L6) answer adds over L5
- Treats "never twice" as something to prove, and proves it through a pause, a clock step and a lost lease.
- Knows exactly what a fencing token protects (the lease row) and what it doesn't (IDs already issued), and puts the fence where the IDs are.
- Chooses which clock measures what: wall clock inside the ID and for the fence, monotonic for timers.
- Does the bit-budget math before moving bits, and rations before migrating.
- Sizes the fleet for AZ loss without new workers, and ties lease length to how long the lease table might be down.
- Writes down the ordering guarantee instead of letting callers assume one.
Follow-up questions
-
"Why not just make the lease 1 hour and forget about renewals?" Answer: the fence keeps it safe at any length, so it's a question of cost. A dead holder's number would stay unusable for an hour. With 64 service numbers and deploys needing 24, a bad afternoon of crash loops could exhaust the service range. Five minutes covers realistic lease-table outages and keeps reuse quick.
-
"A worker's clock jumps forward by 10 minutes. What happens?" Answer: uniqueness holds; ordering doesn't, unless we guard it. Right after the jump, its IDs' timestamps are past its fence, so it refuses. But its next renewal succeeds: the renewal only checks the epoch and that the expiry moves forward, and "now + 5 min" by a fast clock is further forward. From then on it would issue IDs that are still unique (number 37 is still only its own) but carry timestamps 10 minutes in the future, so they sort after everything the rest of the fleet makes for the next 10 minutes. That's why each worker also refuses, and fails its health check, whenever chrony reports it unsynchronized or more than 5 ms from the reference time, and why the clock-offset alarm fires on forward jumps as well as backward ones.
-
"Could we skip the lease table and use the instance's private IP, since IPs are unique in a VPC at any moment?" Answer: unique among running instances, yes, but IPs are reused as soon as an instance terminates, and 10 bits can't hold a VPC's address range without hashing, which brings back collisions. The replacement instance with a reused IP could also produce IDs in the same milliseconds as the previous owner if clocks differ. The lease table gives us the rule "the new holder starts after the stored expiry", and nothing about IPs gives us that.
Interview gotchas from this round
| Gotcha | Why it's wrong |
|---|---|
| "The fencing token protects the IDs" | The IDs carry no token and nobody checks them against the lease. The token fences renewals; the timestamp fence protects IDs. |
| "IDs are strictly ordered" | Only per worker. Across workers, they're k-sorted within clock skew, plus buffer age. |
| "The heartbeat thread will stop a paused worker" | Request threads wake at the same moment. The check has to be on every ID. |
| "A longer lease is less safe" | With the timestamp fence, a longer lease only slows reuse of dead numbers. |
Round 3 Β· Architect Β· "Global, Forever, and Safe to Expose"
~45 min Β· Principal (L7) Β· 5 regions Β· ~2M IDs/s peak Β· 99.999% for issuing IDs Β· no cross-region call on the request path
R3.0 Where We Left Off
This is what the candidate says aloud in the first 60 seconds of Round 3. If you're starting here, it's everything you need from Rounds 1 and 2.
Round 2 in 60 seconds. "We run the company's ID service in one region across three AZs: 100,000 IDs a second on average, 300,000 at peak, 99.99%. IDs are 64-bit Snowflake IDs: 41 bits of milliseconds since 2026-01-01, 10 bits of machine number, 12 bits of sequence, so 69.7 years, 1,024 machines and 4,096 IDs per millisecond per machine. Twelve workers, four per AZ, run behind an internal NLB, and six approved high-volume callers run the generator as a library in about 200 of their own processes. Everyone leases a machine number from one DynamoDB table: a 5-minute lease, renewed every 10 seconds, with an epoch so stale renewals fail. The key rule is the timestamp fence: a holder never issues an ID timestamped within 5 seconds of its lease expiry, and a new holder only starts after the stored expiry, so two holders of the same number can never produce the same ID, whatever pauses or clock skew happen. IDs are k-sorted, not strictly ordered. About $1,050 a month. Four costs are still open: it's one region; the timestamp runs out in 2095; IDs reveal volume and timing; and it's 64-bit only."
The layout (v1)
textβββββββ¬βββββββββββββββββββββββββββββββββββ¬ββββββββββββββββ¬βββββββββββββββββ β 0 β timestamp: ms since 2026-01-01 β machine (10) β sequence (12) β βββββββ΄βββββββββββββββββββββββββββββββββββ΄ββββββββββββββββ΄βββββββββββββββββ
Architecture v2, compact
textmost services ββ(mTLS, batches, buffers)βββΊ internal NLB βββΊ 12 Γ c7g.large (4 per AZ), numbers 0β63 6 big callers: embedded library in ~200 processes, numbers 64β1,023 everyone βββΊ DynamoDB idgen-leases: conditional claim (+1 epoch), renew every 10 s, 5 min lease fence: never issue ts β₯ expiry β 5 s; new holder starts after the stored expiry chrony on every host βββ Amazon Time Sync; refuse if > 5 ms off or clock went back > 5 ms
Steps so far
| Step | Problem | Component |
|---|---|---|
| 1.2β1.3 | 64 bits, sorted, many per ms | 1 + 41 + 10 + 12 Snowflake layout, custom epoch |
| 1.4 | Unique machine numbers | DynamoDB leases with a conditional claim |
| 1.5 | Clocks go backwards | last_ms check; chrony slewing; wait β€ 5 ms, refuse beyond |
| 1.6 | JavaScript precision | String IDs |
| 2.1 | Hop per ID | Batches, client buffers, embedded library |
| 2.2 | Frozen worker | Timestamp fence + renewals checked on holder and epoch |
| 2.3 | Machine numbers run out | Bit math; ranges; rationing |
| 2.4 | Hot caller | Several connections, quotas, saturation alarm |
| 2.5 | Lease table outage | 5 min lease, spare capacity |
| 2.6 | Order across workers | Documented k-sorting |
Open costs: one region; the timestamp ends on 2095-09-07; IDs reveal volume and timing; 64-bit only.
R3.1 The Scope Raise
Interviewer: "We're going global: five regions, each creating records on its own, and we must survive losing a whole region. Peak is about 2 million IDs a second worldwide. Order IDs now appear in public URLs, and we've learned a competitor is estimating our daily order volume from them. We also acquired a company whose IDs are all UUIDv7. And the board wants this platform to outlive your 69-year timestamp without a flag day, meaning no single day when everything must switch at once."
Again, we ask back before we design:
| We ask | Interviewer answers | What it changes in the design |
|---|---|---|
| May a region call another region to get an ID? | No. Each region must issue IDs even if every other region is unreachable. | No global counter and no global lease table on the request path or at start-up. Regions must be unable to collide by construction. |
| How many regions, ever? | Five now, maybe a few more over the years. | Region bits must cover growth, and region numbers must never be reused. |
| Must IDs from different regions be ordered? | Analytics sorts by ID today. | We must say plainly that IDs are not a cross-region clock, and give teams that need order another tool. |
| Must the public ID still be the same number as the database key? | No, as long as links keep working forever. | We can show customers a different, opaque ID and keep the time-ordered one internal. |
| Do the acquired company's UUIDv7 IDs have to become 64-bit? | No, but both kinds must work in shared APIs. | Two formats live side by side. APIs must tell them apart. |
| Is changing existing IDs ever allowed? | Never. Old IDs are in URLs, logs and other companies' systems. | Any new layout must leave every issued ID valid, with no rewrite and no flag day. |
Scope change
| Round 2 | Round 3 | |
|---|---|---|
| Footprint | 1 region, 3 AZs | 5 regions, each issuing on its own |
| Traffic | 100K average, 300K peak IDs/s | ~670K average, ~2M peak IDs/s |
| Survive | An AZ | A region |
| Availability | 99.99% | 99.999% for issuing IDs (about 26 s/month) |
| Request path | Inside one region | No cross-region call, ever |
| Exposure | Internal only | Public URLs; IDs must not leak volume or timing |
| Formats | 64-bit only | 64-bit and 128-bit UUIDv7 side by side |
| Time horizon | Until 2095 | Past 2095, with no flag day |
R3.2 What Breaks in the Round 2 Design
| Round 2 choice | What breaks at the new scope |
|---|---|
| 10 machine bits, one lease table | Five regions share one space of 1,024 numbers. Either they coordinate across regions to hand them out, or two regions can hand out the same number. |
| One lease table in one region | If each region claims from it, start-up in every region depends on that one region. |
| "Order is as good as clock sync" | Clock sync is looser across regions than inside one, and IDs made in different regions in the same millisecond are ordered by machine field, not time. |
| IDs in URLs | Each ID reveals its exact creation time, its machine and its position within the millisecond. Collected in bulk, they reveal volume. |
| 64-bit only | The acquired company's 128-bit UUIDv7 IDs don't fit our API or our BIGINT columns. |
| 41 bits from 2026 | The timestamp field ends on 2095-09-07. Every generator must stop by then. |
R3.3 New Requirements and API Additions
- Region-aware IDs. Every ID says which region made it, and a region registry assigns each region a number that is never reused.
- A public-facing ID that reveals nothing, used in URLs and anything a customer sees.
- A plan for mixed 64/128-bit IDs, and for the timestamp's end.
- A documented ordering guarantee across regions.
- Decode now returns the region and the layout version:
httpGET /v1/ids/683244571854022659/decode HTTP/1.1 Host: idgen.internal
httpHTTP/1.1 200 OK Content-Type: application/json { "id": "683244571854022659", "layout": "v2", "created_at": "2031-03-01T09:30:00.000Z", "region": "eu-west-1", "region_number": 2, "machine": 170, "sequence": 3 }
R3.4 Design Evolution: Regions, Decades and Exposure
Step 3.1: Five Regions Must Issue IDs Independently
The problem: five regions each run generators. Nothing may cross a region on the request path, and a region's generators must start even if every other region is unreachable. Yet no two regions may ever produce the same ID. What would you do?
The v2 layout, proved
textbit 63 62 ββββββββββββββββββββββββ 22 21 β 19 18 ββββββ 10 9 ββββββ 0 βββββββ¬ββββββββββββββββββββββββββββββββ¬βββββββββ¬ββββββββββββββ¬ββββββββββββ β 0 β timestamp: ms since 2026-01-01β region β machine (9) β seq (10) β β (1) β (41 bits) β (3) β 0 β¦ 511 β 0 β¦ 1,023 β βββββββ΄ββββββββββββββββββββββββββββββββ΄βββββββββ΄ββββββββββββββ΄ββββββββββββ id = (ts << 22) | (region << 19) | (machine << 10) | sequence
Example: an order in eu-west-1 (region 2), machine 170 (an embedded library in the order service), sequence 3, at 2031-03-01T09:30:00.000Z, which is 162898200000 ms after our epoch:
textid = (162898200000 << 22) | (2 << 19) | (170 << 10) | 3 = 683244571854022659
Changing the layout without a flag day: let the timestamp choose. Both layouts keep the timestamp in the same 41 bits, so any reader can find an ID's timestamp before knowing its layout. We pick a cutover time, T = 2028-01-01T00:00:00Z (63,072,000,000 ms after the epoch):
- An ID whose timestamp is before T is decoded as v1; at or after T, as v2.
- Every generator gets the v2 configuration months ahead. A generator without it refuses to issue any ID timestamped at or after T.
- In the weeks before T, each generator in us-east-1 also claims a v2 number from the new region-local lease table. It uses its v1 number for timestamps before T and its v2 number after.
Uniqueness across the switch follows from the same idea as the Round 2 fence: every v1 ID has a timestamp before T and every v2 ID has one at or after T, so they can't be equal. The only coordinated moment is a time written in a config file, not a day when everyone changes code.
Primitive: Distributed Unique ID Generators
Step 3.2: A Region Is Gone
The problem: ap-northeast-1 goes down. Its generators, its lease table and its callers are all unreachable, for hours. What would you do about ID generation, both during the outage and when the region comes back?
Primitive: Cloud Disaster Recovery & Multi-Region Active-Active
Step 3.3: Which of These Two IDs Came First?
The problem: a customer changes their address in eu-west-1, then cancels the order through a request that lands in us-east-1. An analytics job orders events by ID and processes the cancel before the address change. What would you do?
Step 3.4: A Competitor Counts Our Orders From Our Public IDs
The problem: order URLs look like /orders/683244571854022659. Anyone can decode that: created 2031-03-01 at 09:30:00.000 UTC, in eu-west-1, on machine 170, the fourth ID of that millisecond. A competitor collects thousands of these (test orders, shared links, receipts posted online) and estimates our volume by region and hour.
What would you do?
Step 3.5: Two ID Formats, and the Timestamp Runs Out in 2095
The problem: the acquired company's services use 128-bit UUIDv7 IDs. Our generators must stop on 2095-09-07, when 41 bits of milliseconds run out. The board wants no flag day. What would you do?
UUIDv7 next to Snowflake
textUUIDv7 (RFC 9562), 128 bits ββββββββββββββββββββββββββββββββ¬βββββββ¬βββββββββββ¬βββββ¬ββββββββββββββββββββββββββββββββ β unix_ts_ms (48) β ver β rand_a βvar β rand_b (62) β β ms since 1970-01-01 β 0111 β (12) β 10 β β ββββββββββββββββββββββββββββββββ΄βββββββ΄βββββββββββ΄βββββ΄ββββββββββββββββββββββββββββββββ Snowflake v2 (ours), 64 bits βββββ¬βββββββββββββββββββββββββββ¬βββββββββ¬ββββββββββββ¬βββββββββββ β 0 β ms since 2026-01-01 (41) β region β machine β sequence β β β β (3) β (9) β (10) β βββββ΄βββββββββββββββββββββββββββ΄βββββββββ΄ββββββββββββ΄βββββββββββ
| Snowflake v2 | UUIDv7 | |
|---|---|---|
| Size | 8 bytes | 16 bytes |
| Timestamp | 41 bits of ms from 2026, ends 2095 | 48 bits of ms from 1970, ends around the year 10889 |
| Uniqueness | By construction: region, machine and sequence | By chance: 74 random bits (when rand_a and rand_b are both random) |
| Coordination | A leased machine number per generator | None |
| Sorts by time | Yes, k-sorted | Yes, by millisecond |
RFC 9562 also lets an implementation use rand_a for extra timestamp precision or a counter, so IDs made by one process in the same millisecond stay in order; that trades random bits for ordering. It also defines version 8 for custom layouts, which is one way to carry a 64-bit ID inside a 128-bit column if a single column type is ever needed.
Primitive: Distributed Unique ID Generators
Step 3.6: Should We Even Run This Service?
The problem: the CTO asks: "Every new service could just generate UUIDv7 in its own process. Why does a team run a generator fleet, lease tables and clock alarms in five regions?" What would you do?
R3.5 Global Architecture
Synthesizing vector architecture diagram...
Each region is complete on its own: its generators, its lease table and its callers never cross a region on the normal path. The region registry is the only thing shared, and it changes only when a region is added. The dotted cross-region arrow is used only when a region's own generators are down. Customers only ever see the public ID.
Trace 1: an order is created in eu-west-1 and shown publicly.
Synthesizing vector architecture diagram...
The internal ID is made in memory with no network call and never leaves the company. The public ID reveals nothing, so the competitor's decoder gets nothing.
Trace 2: eu-west-1's generator fleet fails. Its NLB health checks fail for all 24 workers. The client library in eu-west-1 services tries its local endpoint, gets connection errors, and moves to its configured backup, the us-east-1 endpoint, through Transit Gateway inter-region peering. Those IDs carry region number 0. Embedded libraries in eu-west-1 don't notice at all. When the eu-west-1 fleet is healthy again, clients move back after their next health probe. No machine number or lease moved anywhere.
R3.6 Numbers and Cost
Peak per region (a planning split)
| Region | Number | Peak IDs/s |
|---|---|---|
| us-east-1 | 0 | 600K |
| eu-west-1 | 2 | 500K |
| us-west-2 | 1 | 400K |
| ap-northeast-1 | 3 | 300K |
| ap-southeast-2 | 4 | 200K |
| Total | 2.0M |
Average is about a third of peak, as in Round 2: β 667K IDs/s.
Fleet per region. We keep the planning figure of 50,000 requests/s per c7g.large and still assume one ID per request. We run the same shape everywhere, 24 workers, 8 per AZ, which is 1.2M requests/s of capacity per region. That covers the two cases we plan for, one at a time:
- Losing an AZ in the busiest region: peak (75% busy).
- Absorbing another region's callers: the worst pair is us-east-1 taking eu-west-1's callers: (92% busy).
Smaller regions have more headroom than they need; one fleet shape everywhere keeps operations simple, and at $53 per worker per month the extra is cheap.
Per-machine headroom. Each machine can make 1,024 IDs per millisecond. A worker at its planned 50,000 requests/s averages 50 per millisecond, so the sequence only matters for batches: one batch of 1,000 fits in a millisecond, and two in the same millisecond spill into the next.
The v2 bit budget (step 3.1): 8 regions, 512 machines per region, 1,024 IDs per ms per machine, 41-bit timestamp ending 2095-09-07.
Downstream key storage: the biggest real cost is in the callers' databases.
| Key size | Per copy of the key, per year |
|---|---|
| 8 bytes | TB |
| 16 bytes | TB |
If every ID were stored as a key about three times (primary key, one foreign key, one secondary index), 16-byte keys would add about TB a year. At an illustrative $0.10 per GB-month (the Aurora standard storage price in us-east-1), each year of such data would cost about $50K more per month, every month it's kept, before any cross-region copies. (Aurora's price already covers its six copies across three AZs, and its replicas share that storage.) That is an upper bound, since not every ID becomes a stored key, but it's the right order of magnitude to compare with the fleet below.
Fleet and lease-table cost (us-east-1 prices; other regions cost somewhat more)
| Item | Math | Monthly |
|---|---|---|
120 Γ c7g.large | 5 Γ 24 Γ $0.0725/h Γ 730 h | β $6,350 |
| 5 NLBs, fixed | 5 Γ $0.0225/h Γ 730 h | β $80 |
| NLB capacity units | ~200 B Γ 667K/s average β 480 GB/h β 480 units Γ $0.006 Γ 730 h. An upper bound: embedded-library IDs skip the NLB, and batches carry many IDs per request | β€ $2,100 |
| 5 lease tables | per region (24 + ~200) holders Γ· 10 s β 22.4 writes/s β 58.9 M writes Γ $0.625/M β $37 | β $185 |
| CloudWatch | β $250 | |
| Total | β $9,000/month |
Cross-region traffic only flows during a fleet failure, over the company's existing Transit Gateway peering, so we don't count it here.
The public-ID mapping. Assume 30 million public orders a day, which is about a year. Each needs a 16-byte public_id column and a unique-index entry holding that value and a pointer to the row (about 40 bytes), say 50 bytes with overhead:
The encrypted option would cost almost nothing in dollars (one KMS key at $1 a month and a few KMS calls at start-up), but it carries the key-leak risk from step 3.4.
The lesson in one line: the generator costs about $9K a month; the size of the keys it hands out costs the company far more. That's why step 3.6 turns on key size, not on the service's bill.
R3.7 Trade-Offs
| Choice | We chose | What we give up |
|---|---|---|
| Region bits vs machine bits vs sequence bits | 3 + 9 + 10 | Only 8 regions ever; 1,024 IDs per ms per machine instead of 4,096; a one-time layout change |
| Random vs encrypted public IDs | Random, stored with the entity | A column, an index and a lookup per public entity; encrypted IDs would need no storage but make one key able to expose everything |
| Keep Snowflake vs move to UUIDv7 | UUIDv7 for new systems; Snowflake stays for 64-bit needs | Two formats for decades; 8 more bytes per key in new data |
| Service vs no service | A smaller service for the users who need 64 bits | A team on call for a shrinking service; a clear rule for who may join it |
The opening question was: how do we guarantee no two IDs are ever the same, without machines talking to each other on every request? The answer is now: by construction (region, machine and time bits, fenced by leases), per region (no region ever asks another), and hidden from the public (customers see only an opaque ID). For most new systems, the answer is even simpler: 74 random bits, and a unique constraint as the safety net.
R3.8 Failure Modes
| Failure | What you'd see | How the design responds |
|---|---|---|
| A region outage | All health checks in one region fail | Other regions carry on untouched. Callers that move regions get IDs there, marked with that region's number. Nothing about machine numbers moves (step 3.2). |
| A region returns with a clock behind its last issued IDs | Claims fail with ConditionalCheckFailedException; clock-offset alarm | A generator can't claim any number whose stored expiry is ahead of its clock, so it can't reuse a timestamp range; the chrony offset check keeps it out of service until its clock is synced. If the lease table was restored from backup, the restore procedure bumps every expiry and offsets every epoch first. |
| A region number reused by mistake | Decoded IDs show a region that doesn't match where they were made; possibly duplicate-key errors downstream | Prevented by the start-up check (configured number must match the actual AWS region) and a registry that never reassigns numbers. If it happens: stop the misconfigured generators, and run the duplicate-ID procedure (R3.9) for the affected time window. |
| A public-ID key leaks (encrypted option only) | Security finding, or public IDs decoded in the wild | Rotate: new public IDs use the new key version; old links keep working because the old key is kept for decoding, but IDs made with it must be treated as exposed. This risk is why random public IDs are the default. |
| The end of the timestamp range approaches | The yearly "years left" check drops below 10 (in 2085) | Move each remaining 64-bit table to UUIDv7 before 2095-09-07; generators refuse to issue any timestamp past . |
R3.9 Runbook and Incident Response
Golden signals, per region OPS 8 Β· REL 6
| Signal | Alarm | Severity | First action |
|---|---|---|---|
| Clock offset per host (chrony), either direction | > 1 ms for 5 min / > 5 ms | P3 / P2 | Check chrony on the host; replace the host if it doesn't recover |
| Clock-backward refusals, or claims failing because a host's clock is behind | any | P2 | Find what set the clock; keep the host out of service |
| Lease renewal failures | on many holders in a region | P1 | Check the region's DynamoDB health; pause deploys; the 5 min lease gives time |
| Fence refusals | any | P3 | Look for pauses or renewal failures on that holder |
| Sequence saturation | > 1% of ms on a worker for 5 min | P3 | Find the hot caller; move it to batches or more connections |
| Request P99 latency per region | > 5 ms for 5 min | P2 | Check worker CPU and connection counts; is one caller pinned to one worker? |
| Free machine numbers per region | < 20% of a range | P3 | Find leaked numbers before approving new embedded callers |
| Duplicate-key errors reported by callers | any | P1 | Start the duplicate-ID procedure below |
| Years left on the 41-bit timestamp | < 10 | planning | Start the UUIDv7 migration plan for remaining tables |
Duplicate-ID incident procedure OPS 10
- Detect. Callers' unique constraints reject the second insert, and a daily Athena job over exported data counts any ID that appears twice.
- Decode the duplicate. The ID itself says the time, region and machine number. That's the whole search space.
- Find the holders. Every claim, renewal and release is logged with holder, epoch and expiry. List who held that machine number in that region around that time.
- Contain. Quarantine the machine number (so nobody can claim it), stop the holders involved, and take their hosts out of service.
- Assess. List the records created with IDs from that number in the window, find which ones collided, and fix them with the owning teams.
- Learn. A blameless Correction of Error (COE). A duplicate means one of our invariants failed (a clock, a restore, a misconfigured region number, a library bypassing the fence), so the action items must close that path for good.
Clock-step procedure. If a host's clock was stepped: take it out of service (the worker has usually done so itself), confirm what stepped it from the chrony log and the instance's events, and replace the host rather than repair it. The replacement claims a fresh number and starts after that number's stored expiry. If many hosts in a region stepped at once, suspect the time source or a bad configuration push, and halt deploys in that region.
Go deeper: CLI playbook
Plain commands an on-call engineer runs, one at a time. Replace the IDs and ARNs with real ones.
text# 1. Who holds which machine number in a region right now aws dynamodb scan --region eu-west-1 --table-name idgen-leases-v2 --projection-expression "machine_id, #h, lease_expiry, lease_epoch" --expression-attribute-names '{"#h": "holder"}' # 2. One machine number's lease aws dynamodb get-item --region eu-west-1 --table-name idgen-leases-v2 --key '{"machine_id": {"N": "170"}}' # 3. Quarantine machine number 170: expiry far in the future (year 2100), new epoch, so nobody can claim or renew it aws dynamodb update-item --region eu-west-1 --table-name idgen-leases-v2 --key '{"machine_id": {"N": "170"}}' --update-expression "SET #h = :q, lease_expiry = :far ADD lease_epoch :one" --expression-attribute-names '{"#h": "holder"}' --expression-attribute-values '{":q": {"S": "QUARANTINED"}, ":far": {"N": "4102444800000"}, ":one": {"N": "1"}}' # 4. Clock status on every generator host in a region aws ssm send-command --region eu-west-1 --document-name "AWS-RunShellScript" --targets "Key=tag:Service,Values=idgen" --parameters 'commands=["chronyc tracking"]' # 5. Which workers the NLB considers healthy aws elbv2 describe-target-health --region eu-west-1 --target-group-arn arn:aws:elasticloadbalancing:eu-west-1:111122223333:targetgroup/idgen-workers/0123456789abcdef # 6. Take one worker out of the NLB aws elbv2 deregister-targets --region eu-west-1 --target-group-arn arn:aws:elasticloadbalancing:eu-west-1:111122223333:targetgroup/idgen-workers/0123456789abcdef --targets Id=i-0a1b2c3d4e5f67890 # 7. Alarms currently firing for the generator aws cloudwatch describe-alarms --region eu-west-1 --state-value ALARM --alarm-name-prefix idgen # 8. Look for duplicate IDs in exported order data for an incident window aws athena start-query-execution --region eu-west-1 --work-group primary --query-string "SELECT id, COUNT(*) AS n FROM orders_export WHERE created_at BETWEEN TIMESTAMP '2031-03-01 09:00:00' AND TIMESTAMP '2031-03-01 10:00:00' GROUP BY id HAVING COUNT(*) > 1"
4102444800000 is 2100-01-01T00:00:00Z in Unix milliseconds. Quarantine works because a claim needs lease_expiry < :now and a renewal needs its own holder name and epoch; this update changes both, so it defeats both. The current holder still stops at its own fence, so command 6 (or stopping the process) is what stops it immediately.
R3.10 Pillar Check
| Pillar | What Round 3 adds |
|---|---|
| Reliability | Region bits make regions independent by construction; region-local lease tables; each region survives an AZ loss and can absorb another region's callers; the restore procedure (expiry bump and epoch offset) for a returning region; a layout change and a format change with no flag day REL 10 Β· REL 11 Β· REL 13 |
| Performance Efficiency | No cross-region call on the request path; embedded generation for the largest callers; per-region fleets sized from the math PERF 1 Β· PERF 4 |
| Security | IDs treated as data that leaks (time, place, volume); an opaque public ID at the edge; random public IDs by default so there's no key to leak; where encryption is used, the key is protected by KMS and kept only in memory SEC 7 Β· SEC 8 Β· SEC 10 |
| Cost Optimization | β $9K/month for the fleet, derived; the real cost driver found (key size in callers' databases); UUIDv7 by default where 8 extra bytes cost less than running anything COST 5 Β· COST 11 |
| Operational Excellence | Per-region golden signals with first actions; a duplicate-ID procedure that starts from decoding the ID; a clock-step procedure; a 10-year warning for the timestamp's end; COEs OPS 8 Β· OPS 10 Β· OPS 11 |
| Sustainability | The generator itself is tiny: 120 small Graviton instances worldwide. Its real footprint is downstream: every byte of key size is stored, indexed, cached and replicated many times, so 8-byte keys where volume is huge, and a deliberate choice rather than a default everywhere else SUS 4 Β· SUS 5 |
R3.11 Round 3 Rubric and Follow-Ups
What an architect (L7) answer adds over L6
- Sets a company-wide ID strategy: which format for which use, who may use the service, and what the ordering guarantee is.
- Uses bit budgets as a design tool: region bits make regions independent by construction, and the timestamp doubles as a layout version.
- Plans migrations with no flag day: the layout cutover by timestamp, and UUIDv7 for new data while old IDs stay valid forever.
- Treats IDs as information that leaks, and separates internal and public identity.
- Finds where the cost really is (key size in other teams' databases) and lets that drive the decision.
- Is willing to answer "do we even need this service?" with numbers, and to shrink their own system.
Follow-up questions
-
"We add a sixth, seventh and eighth region, and then a ninth. What happens?" Answer: three bits give eight region numbers, and retired numbers are never reused, so the ninth region can't get one. Options: the ninth region runs UUIDv7 only, which step 3.6 makes the default anyway; or we change the layout again, for example 41 + 4 + 8 + 10, with another timestamp cutover. We'd know years ahead, because the registry shows how many numbers are left.
-
"Why not encrypt the internal ID and skip storing public IDs? It's free." Answer: it's free in dollars. But one key then protects every public ID we've ever issued: if it leaks, anyone can decode all of them, and nothing can un-leak the old links. Rotating the key changes every public ID unless we version it. A random public ID has nothing to leak, and it costs about $55 a month per year of orders. Encryption is the right tool only when a table can't take a column.
-
"A team wants to generate Snowflake IDs in a browser app to create records offline." Answer: no. A browser can't hold a lease reliably, its clock is whatever the user set, and it would need a machine number per device. Browsers use UUIDv7 (or UUIDv4) generated locally, which is unique by chance, and the server can assign a Snowflake ID later if the table needs one.
Loop Closer: Interview Strategy for All Three Rounds
How to Run Each 60-Minute Round
| Time | Round 1 | Round 2 | Round 3 |
|---|---|---|---|
| 0β5 min | Scoping questions: sorted? gaps? size? | Restate the Round 1 design in 60 seconds | Restate the Round 2 design in 60 seconds |
| 5β15 min | Requirements + API (string IDs) | Scope raise β what breaks | Scope raise β what breaks |
| 15β40 min | Steps 1.0β1.6: counter β UUID β Snowflake β sequence β leases β clocks β JavaScript | Steps 2.1β2.6: batches, the timestamp fence, bit rationing, hot callers, lease length, k-ordering | Steps 3.1β3.6: region bits and cutover, region loss, cross-region order, public IDs, UUIDv7, "no service" |
| 40β50 min | Bit budget + numbers + trade-offs | Numbers, cost, trade-offs | Numbers, cost (downstream keys), trade-offs |
| 50β60 min | Failures + pillar check | Failures + pillar check | Failures, runbook, pillar check |
For how to spend a single 45-minute round, see the 45-minute interview blueprint.
The Two Sentences That Matter Most
- Opening a round: "Before I design, let me ask a few scoping questions: must IDs be strictly ordered, and are gaps allowed?"
- When the scope is raised: "Here's what breaks in the current design, and here's the order I'll fix it in, starting with anything that could produce a duplicate."
Well-Architected Review Sheet
Interviewers rarely ask "which pillar is this?". They ask the pillar's question in plain words. Rehearse one sentence per row.
| Pillar | Question you'll hear | One-sentence answer | Round | Backed by |
|---|---|---|---|---|
| Reliability | "What happens when a generator dies?" (REL 11) | The others carry the load; its lease expires and its number is reused later, by a holder that starts after the stored expiry. | 1 | R1.9 |
| "What if a worker freezes and wakes up after its lease was taken?" (REL 5) | It can't issue a timestamp past its fence, and the new holder only issues after the stored expiry, so their IDs can't meet. | 2 | Step 2.2 | |
| "What happens when an AZ goes down?" (REL 10) | Four workers per AZ means the other eight carry peak at 75%, with no new workers needed. | 2 | R2.6 | |
| "What if the coordination store is down?" (REL 5) | Running holders keep issuing for up to 4 min 55 s on a 5-minute lease; only start-up is blocked. | 2 | Step 2.5 | |
| "What if a whole region goes down?" (REL 13) | Nothing moves: other regions issue with their own region bits, and the returning region can't issue behind its old IDs. | 3 | Step 3.2 | |
| Performance | "How do you keep ID generation off the critical path?" (PERF 1) | IDs are built in memory; batches, client buffers and an embedded library remove the hop. | 1β2 | Step 2.1 |
| "What's your throughput limit?" (PERF 2) | 4,096 IDs per ms per machine (1,024 in v2); in practice the request rate per worker, which we load-test. | 1β3 | R1.7, R3.6 | |
| Cost | "What does it cost?" (COST 5) | About $110, $1,050 and $9,000 a month; the real cost is key size in callers' databases. | 1β3 | R1.7, R2.6, R3.6 |
| "Should we just use UUIDs?" (COST 11) | For most new systems yes, UUIDv7 in-process; keep 64-bit IDs where key size or BIGINT schemas really matter. | 3 | Step 3.6 | |
| Operations | "How do you know it's healthy?" (OPS 8) | Clock offset, refusals, lease renewals, saturation and P99, each with a first action. | 2β3 | R2.10, R3.9 |
| "What do you do when a duplicate is found?" (OPS 10) | Decode it: time, region and machine give the search space; find the holders, quarantine the number, fix the records, write a COE. | 3 | R3.9 | |
| Security | "Who can get IDs, and who can touch the leases?" (SEC 3) | Callers authenticate with mutual TLS and have quotas; only generator roles can update the lease table. | 1β2 | R2.3 |
| "Do your IDs leak anything?" (SEC 7) | Yes: time, region, machine and volume, so customers only see random opaque public IDs. | 3 | Step 3.4 | |
| Sustainability | "Where is this system's footprint?" (SUS 4) | Not the fleet: the bytes of every key stored and indexed downstream, which is why key size is a deliberate choice. | 2β3 | R2.6, R3.6 |
Rubric Across Levels
| Dimension | L5 (Round 1) | L6 (Round 2) | L7 (Round 3) |
|---|---|---|---|
| ID structure | Derives 1 + 41 + 10 + 12 and proves 69.7 years, 1,024 machines, 4,096 per ms. | Moves bits only with the math; rations machine numbers before changing the layout. | Adds region bits; changes the layout with a timestamp cutover; plans the move to UUIDv7. |
| Machine numbers | Leased from a table with a conditional write, not hard-coded. | Proves uniqueness through pauses and lost leases with the timestamp fence; knows what the fencing token does and doesn't protect. | Region-local leases with no cross-region dependency; a registry that never reuses numbers; restore rules. |
| Clocks | Wall clock in the ID; remembers the last timestamp; waits or refuses; chrony slews. | Wall clock for the fence, monotonic for timers; refuses when out of sync in either direction. | Separates ordering from causality across regions; per-entity versions or HLCs where order matters. |
| Ordering | Knows IDs are roughly sorted and why that helps a B-tree. | Documents k-sorting; knows strict or gapless order needs a single sequencer. | Writes the company-wide guarantee: per generator, per region, across regions. |
| Exposure and clients | String IDs for JavaScript. | Decode endpoint and client-side decoding; quotas per caller. | Internal vs public IDs; random vs encrypted, with the key-leak trade-off. |
| Well-Architected trade-offs | Says the service is tiny and throughput isn't the hard part. | Sizes for AZ loss; ties lease length to dependency outages; prices the fleet. | Finds the real cost in key size downstream; answers "do we need this service?" with numbers. |
| Evolving under new scope | Builds from one counter, one problem at a time. | Opens with "what breaks", fixes duplicates first, then latency and capacity. | Evolves the design and the company's rules: formats, registry, public IDs, who may join the service. |