Part 4 · Lesson 4.3
Two Posts With the Same ID
Where we are: Chirp's IDs have come from Postgres since day one: every table has a bigserial column that counts up 1, 2, 3, and so on. Lesson 4.2's outbox needed an ID for each event before anything was published, and Part 5 will need IDs that can be made on more than one database. But the first reason Chirp changed its IDs had nothing to do with either.
What you’ll learn
- What a sequential ID gives away, and why that matters
- UUIDs, Snowflake IDs and their relatives, with the trade-offs of each
- Why time-ordered IDs are kinder to databases than random ones
- The clock and machine-ID problems that come with generating IDs yourself
Lesson 4.3 Two Posts With the Same ID
Step 1 of 15 · 1 min · The story
In March, a tech journalist published a short article about Chirp's growth. It included a chart of new sign-ups per day, going back eight months, which was uncomfortably accurate. Chirp had never shared those numbers.
The method was in the last paragraph. Chirp's profile URLs looked like chirp.app/u/5820117. The journalist had created a new account every day at noon, and noted its user ID. Yesterday's account was 5,804,962; today's was 5,820,117. So Chirp had gained about 15,000 users in a day. Do that for a few weeks, and you have a growth chart.
The same week, the security team noticed something worse. Someone had been requesting chirp.app/api/posts/1, then /posts/2, then /posts/3, and so on, millions of them, downloading every public post in order. Worse, a bug in one endpoint returned posts from private accounts if you asked for them by ID. Sequential IDs had made finding them trivial.
Then, a month after Chirp moved to a new ID scheme to fix all this, two different posts were created with exactly the same ID. Generating IDs turns out to have its own failure modes.
Step 2 of 15 · 1 min
What a sequential ID gives away
Auto-incrementing IDs are a great default: small (8 bytes), sorted by creation time, easy to read, and generated by the database with no extra work. But once they're visible to users, in URLs or API responses, they leak information:
- How fast you're growing. Two IDs at two times give you a rate, as the journalist showed. There's a famous version of this from the Second World War, the German tank problem: Allied statisticians estimated German tank production from the serial numbers on captured tanks, and were far more accurate than intelligence reports.
- How big you are. Your newest ID is roughly your total count.
- Where everything is. If post 9001 exists, so do 9000 and 9002. Anyone can walk the whole collection in order.
The last one makes an authorization bug much worse. Chirp's private-post bug was an insecure direct object reference: the endpoint fetched whichever post ID it was given without checking whether the requester was allowed to see it. That's a bug regardless of the ID scheme, and the fix is the permission check. But with sequential IDs, exploiting it took a loop. With unpredictable IDs, an attacker would have to guess, which is much harder.
So the first lesson is: unguessable IDs make some attacks harder, but they never replace checking permissions.
Step 3 of 15 · 1 min
Why else you might not want the database to choose
Even without the privacy issues, Chirp was running into places where waiting for Postgres to assign an ID was awkward:
- The outbox (lesson 4.2) and events in general. An event often needs to refer to an ID before anything has been inserted, or across several inserts.
- Idempotency (lesson 1.5). If the phone creates the post's ID itself, a retried request carries the same ID, and the server can recognize the duplicate. That's an idempotency key built in.
- Offline apps. A phone with no signal can create a draft post with a real, final ID, and upload it later.
- More than one database. In Part 5, posts will be spread across several databases. Each database counting 1, 2, 3 on its own would produce collisions immediately.
So the goal became: IDs that any machine (or phone) can create on its own, that never collide, and that don't reveal how many there are. There are a few well-known ways to do it.
Step 4 of 15 · 1 min
UUIDs
A UUID (universally unique identifier) is a 128-bit number, usually written as 36 characters:
f47ac10b-58cc-4372-a567-0e02b2c3d479The most common version, UUIDv4, is almost entirely random: 122 random bits, plus a few that mark it as a version 4 UUID. Anyone, anywhere, can generate one with no coordination at all.
Can two random UUIDs collide? In principle yes; in practice, no. With 122 random bits, the usual estimate is that you'd need to generate about a billion UUIDs every second for around 85 years to have an even chance of a single collision. A bad random number generator is a far more realistic risk than chance.
UUIDv4 has two real costs:
- Size. 16 bytes instead of 8, in every primary key, every foreign key and every index entry. Across billions of rows and several indexes, that adds up. It's also long in URLs.
- Randomness hurts the database. Lesson 3.2 showed that inserting random keys into a B-tree index scatters writes across the whole index, touching a different page each time. Inserting increasing keys just appends to the last page. For a table receiving thousands of inserts a second, random UUIDs as the primary key mean much more disk writing, more page splits, and a bigger index.
Step 5 of 15 · 2 min
Time-ordered IDs
The fix for the second problem is to put the time first, so IDs created later are larger. They then append to the end of the index, like auto-increment does, while still being unique without coordination.
UUIDv7
A newer UUID version, UUIDv7, standardized in 2024 in RFC 9562, does exactly this: the first 48 bits are the time in milliseconds, and the rest are random. It's still a 128-bit UUID, so it works anywhere a UUID does, but IDs sort by creation time, and inserts land at the end of the index. If you'd otherwise use UUIDv4, UUIDv7 is almost always the better choice today.
ULIDs are an earlier, similar idea (a 48-bit timestamp followed by 80 random bits, written in a 26-character format that sorts correctly as text).
Snowflake IDs
Twitter needed something smaller, and in 2010 published Snowflake: a 64-bit ID that fits in a normal bigint column. It's built from three parts:
| 1 unused bit | 41 bits: time in ms since a chosen start date | 10 bits: machine ID | 12 bits: sequence |- Time, in milliseconds since a starting point you choose (your epoch). 41 bits of milliseconds lasts about 69 years.
- Machine ID: a number from 0 to 1,023, different for every machine generating IDs.
- Sequence: a counter from 0 to 4,095 that resets every millisecond, for IDs created in the same millisecond on the same machine.
So each machine can create up to 4,096 IDs per millisecond (about four million a second) with no coordination with anyone else. Two machines can never collide, because their machine IDs differ. One machine can never collide with itself, because either the time or the sequence is always different.
The result is an 8-byte number, sorted by time (to the millisecond), that reveals the creation time and the machine but not the count. Many companies use variations of it, sometimes adjusting the bit split (more bits for machines, fewer for the sequence) to fit their needs.
Ticket servers
One more approach is worth knowing, because it's so simple. Flickr published it in 2010: a pair of dedicated databases whose only job is handing out IDs. One gives out odd numbers, the other even, so either can keep working if the other goes down. To reduce load, an app server can ask for a block of IDs at once (say, the next 1,000) and use them locally until they run out.
IDs stay small and roughly increasing. But they're sequential again, so they leak the count, and the ticket servers are a dependency every insert relies on.
Step 6 of 15 · 1 min
Comparing them
| Size | Sorted by time | Needs coordination | Reveals count | |
|---|---|---|---|---|
| Auto-increment | 8 bytes | Yes | The one database | Yes |
| UUIDv4 | 16 bytes | No: random, bad for indexes | None | No |
| UUIDv7 / ULID | 16 bytes | Yes (ms) | None | No |
| Snowflake | 8 bytes | Yes (ms) | Unique machine IDs | No (shows time and machine) |
| Ticket server | 8 bytes | Roughly | The ticket servers | Yes |
Chirp chose Snowflake-style IDs for posts, likes and events, mainly for the 8-byte size, which mattered with tens of billions of rows on the way. It chose UUIDv7 for things that are created rarely and are long-lived, like accounts, where size didn't matter and not depending on machine IDs did.
Step 7 of 15 · 2 min
The two ways Snowflake goes wrong
Generating IDs yourself means taking on two responsibilities the database used to handle. Both bit Chirp.
Two machines with the same machine ID
Remember the two posts with the same ID? Chirp's ID generators got their machine IDs from configuration. When autoscaling (lesson 2.1) added servers, new servers were created from a copy of an existing server's setup, including its machine ID. For a few hours, two servers had machine ID 17. Each was perfectly correct on its own, and between them, they created a few hundred duplicate IDs. Most failed to insert, thanks to the primary key. Two didn't, because they were in different tables that were later joined, and a user saw someone else's post in their own profile.
Machine IDs have to be truly unique across every live generator, and stay unique as machines come and go. Common approaches:
- Assign them centrally, with a lease. On startup, each generator claims a free machine ID from a coordination service or a database table, holds it with a lease that it renews regularly, and gives it up when it stops. If a generator dies, its lease expires and the ID becomes free again, after a safe gap. (Lesson 6.5 explains how coordination services keep this reliable.)
- Derive it from something already unique, like a server's position in a fixed-size group, if your platform provides one. Be careful with things like IP addresses, which get reused.
- Use a dedicated ID service: a few machines that hand out IDs (or blocks of them) to everyone else, so only a handful of machine IDs ever need managing.
Chirp moved to claiming machine IDs with a lease from a small table in Postgres. And the primary key caught most of the damage, which is a good reminder to keep uniqueness rules in the database even when IDs come from elsewhere.
The clock going backwards
Snowflake depends on the clock moving forward. But server clocks drift, and the service that keeps them correct (NTP) sometimes corrects a clock that's running fast by moving it back. If a generator's clock jumps back two milliseconds, it can produce a timestamp it has already used, and with the sequence reset, an ID it has already issued.
The standard protection is simple: the generator remembers the last timestamp it used, and if the clock is ever behind that, it refuses to generate IDs (or waits) until the clock catches up. For tiny corrections, it simply waits a few milliseconds. For a large jump, it reports an error loudly, because something is seriously wrong with that machine's clock. Most NTP setups also slew the clock (speed it up or slow it down gradually) rather than jumping, which helps.
Clocks also affect ordering: IDs from two machines whose clocks differ by a few milliseconds sort slightly out of creation order. That's usually fine (lesson 4.4 discusses it for feeds). If you need a strict order across machines, timestamps can't give it to you, and lesson 6.5 explains why.
Step 8 of 15 · 1 min
One more trap: JavaScript
Chirp's website broke the day Snowflake IDs shipped. Some posts couldn't be opened, and the website's links pointed to slightly wrong IDs.
JavaScript stores all ordinary numbers as 64-bit floating-point values, which can represent whole numbers exactly only up to 2⁵³ (about 9 quadrillion). Snowflake IDs are bigger than that. When the website's code parsed API responses as JSON, an ID like 1849320192837465601 silently became 1849320192837465600, which is a different post or no post at all. (Lesson 4.4 hits the same limit inside Redis.)
The fix is to send large IDs as strings in JSON: "id": "1849320192837465601". Twitter's API famously did this, adding an id_str field alongside id when it introduced Snowflake. Inside the database and backend services, IDs stay 64-bit integers.
Step 9 of 15 · 1 min
From the real world: the leap second that crashed Cloudflare's DNS
"The two ways Snowflake goes wrong" included clocks going backwards. That sounds theoretical until you see what one second did at Cloudflare on New Year's Day 2017.
To keep clocks in line with the Earth's rotation, a leap second was added at midnight UTC between 31 December 2016 and 1 January 2017: the time 23:59:60 existed, once. Cloudflare's DNS software (RRDNS, written in Go) measured how long each upstream server took to answer, by reading the current time before and after a request and subtracting. It used those durations to prefer faster upstreams.
Around the leap second, with the system clock being adjusted, some of those subtractions came out negative. The code assumed a duration could, at worst, be zero. The negative values were later fed into the code that picks an upstream by weight, which panicked. For a while after midnight, some DNS lookups for websites using Cloudflare failed. The fix was small (never record a negative duration), and Cloudflare then audited its code for other places that assumed time only moves forward.
The deeper fix is to know which clock you're reading:
- Wall-clock time (
time.Now()in most languages,System.currentTimeMillis()in Java) is "what time is it?" It can jump forwards or backwards whenever it's corrected. - Monotonic time (
System.nanoTime(),CLOCK_MONOTONIC,performance.now()) only ever goes forward. It's meaningless as a date, but it's the right thing for measuring durations and timeouts.
Go 1.9, released later in 2017, changed Go's time values to carry a monotonic reading automatically, so that subtracting two of them measures elapsed time correctly. Snowflake generators need the same care in the other direction: they do need wall-clock time (it's in the ID), so they must detect the clock going backwards and wait or refuse, instead of trusting it.
Step 10 of 15 · 1 min
Going deeper: ordering events without trusting clocks
Time-ordered IDs sort by when they were created, according to the machine that created them. Machines' clocks agree only roughly, typically within a few milliseconds with good time sync, sometimes much worse. For most things, that's fine. For events that cause each other, it isn't.
Meera posts on server A, whose clock is correct: 10:00:00.100. Kabir replies within milliseconds, and his reply is handled by server B, whose clock is 40 ms slow: 10:00:00.080. Sort by timestamp, and the reply comes before the post it's replying to.
A hybrid logical clock (HLC) fixes this while staying close to real time. Each server keeps a timestamp made of two parts: a physical time, and a small counter. The rules:
- When creating an event, use the larger of your own clock and the last timestamp you used. If it's the same physical time, increase the counter.
- When you receive a message carrying a timestamp, set yours to the largest of your clock, your last timestamp, and the message's timestamp (again bumping the counter if they tie).
Because Kabir's reply was triggered by seeing Meera's post (carrying .100), server B stamps the reply at .100 plus one tick of the counter, after the post. Events that cause each other are always ordered correctly, and timestamps never run far ahead of real clocks.
CockroachDB and MongoDB use HLCs internally. For Chirp, the practical version is cheaper: when a client replies to something, send the parent's ID, and have the server make sure the new ID is greater than the parent's. Snowflake IDs can do that by waiting a millisecond or two if needed.
Step 11 of 15 · 1 min
What Chirp changed
- New posts, likes, follows and events use Snowflake-style 64-bit IDs, generated by each app server, with machine IDs claimed by lease from a table.
- Accounts use UUIDv7. Public URLs for profiles use usernames; public post URLs use the Snowflake ID, which no longer reveals the count.
- Generators refuse to issue IDs if the clock goes backwards, and alert if it happens more than briefly.
- Every API sends IDs as strings in JSON.
- The private-post bug was fixed properly: every endpoint checks the viewer's permission for the specific object, whatever the ID looks like. A test now tries every endpoint with someone else's IDs.
- Existing posts kept their old sequential IDs. New posts got IDs in a range far above them, so the two never overlap and old links still work.
Step 12 of 15 · 1 min
In the simulation
In Stage 3 you choose how IDs are generated. Stage 3's autoscaling event tests whether your machine IDs stay unique as servers come and go, and the dashboard flags duplicate keys. In Stage 4, when Chirp's data is split across several databases, auto-increment IDs stop working altogether unless you planned for it.
Step 13 of 15 · 1 min
If you're here for an interview
"How would you generate unique IDs?" is a classic question, both on its own and inside larger designs like a URL shortener or a chat app. A strong answer:
- States the requirements first: size, sortable or not, generated where, guessable or not.
- Compares auto-increment, UUIDv4, UUIDv7 and Snowflake briefly, with one trade-off each.
- Explains Snowflake's layout and capacity (time, machine, sequence; about 4,096 per millisecond per machine).
- Mentions the failure modes: unique machine IDs and clocks going backwards.
- Bonus: random keys hurt B-tree inserts; large IDs need to be strings in JavaScript.
Step 14 of 15 · 1 min
Try these yourself
1. You see two accounts created exactly one week apart, with sequential IDs 4,210,000 and 4,315,000. Roughly how many sign-ups per day is that?
105,000 IDs in 7 days, so about 15,000 a day, assuming IDs aren't skipped. That's exactly how the journalist built the chart.
2. A Snowflake generator gets 6,000 requests for IDs within a single millisecond. What happens?
The 12-bit sequence only allows 4,096 IDs per millisecond. After that, the generator must wait for the next millisecond before issuing more. So the last 1,904 requests are delayed by up to a millisecond. That's harmless, and it's why sequence bits are sized for the expected peak.
3. A table with 2 billion rows uses UUIDv4 as its primary key and receives 20,000 inserts a second. What would you expect to see, and what would you change?
Inserts scatter across the whole primary-key index, so nearly every insert touches a page that isn't in memory: heavy disk reads and writes, frequent page splits, and an index much bigger than its data needs. Switching new rows to a time-ordered key (UUIDv7 or Snowflake) makes inserts append to the end of the index instead.
4. Why might you choose UUIDv7 over Snowflake, even though it's twice the size?
UUIDv7 needs no coordination at all: no machine IDs to assign, lease or keep unique, so it can be generated on phones, in browsers, or by any service. It also has far more random bits, so it's harder to guess. Snowflake's advantage is size, which matters most for very large, heavily indexed tables.
5. Chirp switches to unguessable IDs. A teammate says the private-post bug is now fixed. Is it?
No. Anyone who gets a private post's ID (from a shared link, a log, a screenshot, a former follower) can still fetch it. Unguessable IDs reduce how easily the bug can be exploited; only checking the viewer's permission on every request fixes it.
Step 15 of 15 · 1 min
Further reading
- RFC 9562, Universally Unique IDentifiers (UUIDs), which defines UUIDv7.
- Twitter Engineering, "Announcing Snowflake" (2010).
- Flickr Code, "Ticket Servers: Distributed Unique Primary Keys on the Cheap" (2010).
- OWASP's material on Insecure Direct Object References (IDOR).
- Any statistics text's account of the German tank problem, for a satisfying piece of history.
- John Graham-Cumming, "How and why the leap second affected Cloudflare DNS," Cloudflare blog, January 2017.
- Go issue 12914 and the Go 1.9 release notes on monotonic time.
- Sandeep Kulkarni et al., "Logical Physical Clocks and Consistent Snapshots in Globally Distributed Databases," 2014 (the hybrid logical clock paper).