How Do Two Servers Never Pick the Same ID?
· Jerwin Arnado · 11 min read ·
Someone new to the team asked me this over coffee, and it’s a better question than it sounds: if you’ve got two servers both handing out IDs, how do they never collide? The naive answer is “the database does it,” and that’s true — until it isn’t. The moment you have two things that can both create a record at the same instant, “just use auto-increment” quietly stops being a plan and becomes a bug waiting for load. The interesting part is that the whole field of solutions comes down to a single question you can ask about any scheme: who owns which piece of the number line?
The model that breaks
On one database, uniqueness is free. AUTO_INCREMENT (MySQL) or a SEQUENCE (Postgres) hands
you 1, 2, 3, 4, and the database guarantees no two callers ever get the same value, because
there is exactly one counter and it’s behind a lock. Clean. It’s why nobody thinks about IDs
until they have to.
Now picture the version everyone writes first when they don’t have that counter — say you’re sharding, or generating IDs in the app before the row exists:
-- two app servers run this at the same time
SELECT MAX(id) + 1 FROM orders; -- both read 4000
INSERT INTO orders (id, ...) VALUES (4001, ...); -- both write 4001 💥
Both servers read MAX(id) as 4000 in the same millisecond. Both compute 4001. Both insert.
One wins, one gets a duplicate-key error — or worse, if there’s no unique constraint, they
both “succeed” and you’ve silently merged two orders onto one ID. This is a race
condition, and it isn’t a rare edge case; it’s the default outcome under concurrency. The
single-counter magic only worked because there was a single counter.
So the real question is: how do you keep that guarantee when there’s no longer one place that owns the count? There are two philosophies, and everything ever built is one or the other.
Philosophy one: coordinate
The first answer is to keep a central authority, but make it fast and make it only do this one job. Instead of every server guessing, they all ask the same source for the next number.
The naive version — one server calls another for every single ID — is a disaster: you’ve put a network round-trip and a single point of failure on the hot path of creating anything. The clever versions fix both problems.
Ticket servers with offsetting (the Flickr trick). Flickr needed global IDs across sharded databases, so they stood up two tiny MySQL boxes whose entire job was to auto-increment. The trick is how they configured them:
Server A: auto_increment_increment = 2, auto_increment_offset = 1 → 1, 3, 5, 7, ...
Server B: auto_increment_increment = 2, auto_increment_offset = 2 → 2, 4, 6, 8, ...
One server hands out odd numbers, the other even. They cannot collide, because they own disjoint halves of the number line by construction — and either one can die without taking the ID service down. That’s the whole idea in miniature: partition the space so coordination isn’t needed per-ID.
Batching (hi/lo). The other fix is to stop asking one-at-a-time. A server asks the central
sequence for a block — “give me 1000 through 1999” — and then hands those out locally with
no network calls until the block runs dry. One round-trip buys a thousand IDs. Hibernate’s
hi/lo generator and Postgres sequence caching both work this way. You trade perfectly gapless
IDs (a server that restarts abandons the rest of its block) for near-zero coordination cost.
Coordination gives you short, sortable, human-friendly IDs. The price is that something, somewhere, is still the authority — and you have to keep it alive.
Philosophy two: don’t coordinate at all
The second answer is bolder: never ask anyone. Each server generates IDs entirely on its own, and you engineer the scheme so that a collision is either impossible or so unlikely you’ll never see one before the heat death of the product. No central server, no round-trip, no single point of failure. This is where UUIDs live.
Random UUIDs (v4). A version-4 UUID is 122 random bits. Each server just rolls the dice:
f47ac10b-58cc-4372-a567-0e02b2c3d479
No coordination, no shared state, works offline, works across a thousand machines that have never heard of each other. The obvious worry is: if it’s random, can’t two of them roll the same number? Technically yes. Practically no, and the math is worth internalizing because it comes up everywhere — it’s the birthday problem. With 122 bits of randomness, you’d have to generate about 2.7 quintillion UUIDs before the odds of a single collision reach one in a billion. At a million IDs per second, that’s on the order of thousands of years. “Unique” here doesn’t mean proven unique the way a counter is; it means the universe will let you down first. For practically every system, that’s a stronger guarantee than your uptime.
So why isn’t everything just random UUIDs? Because they have a nasty performance secret.
The hidden cost of random
Random IDs are murder on databases, and it catches people out. Most databases store rows in a B-tree keyed on the primary key, physically ordered by that key. When your IDs are random, every insert lands at a random spot in that tree:
- Your inserts touch random pages all over the index instead of appending to the end, so the working set that has to stay in memory balloons — you’re constantly pulling cold pages off disk.
- The index fragments and pages split, wasting space and I/O.
- On MySQL/InnoDB especially — where the whole row is stored in the primary-key tree — a random PK turns a tidy sequential write pattern into scattered ones. On write-heavy tables the throughput hit is real and measurable.
Sequential IDs, by contrast, always insert at the “right edge” of the tree — cache-friendly, append-like, fast. So you’re caught between two goods: random gives you coordination-free uniqueness; sequential gives you write performance and sortability. For a long time you had to pick one.
The last decade’s answer is: don’t pick. Put the time in front.
The synthesis: time-ordered IDs
The modern schemes get coordination-free uniqueness and rough sortability by building the ID out of parts, with a timestamp on the left so the values still climb over time.
Snowflake (Twitter, 2010). A Snowflake ID is a 64-bit integer sliced into fields:
0 | 41 bits timestamp (ms) | 10 bits machine id | 12 bits sequence
└ ~69 years from an epoch └ 1024 machines └ 4096 ids/ms/machine
Look at why this can’t collide, because it’s the punchline of the whole post. Two IDs differ if any field differs:
- Different millisecond? The timestamp differs. Done.
- Same millisecond, different machine? The machine id differs — and every machine is handed a unique one, so no two boxes ever share that slice.
- Same millisecond, same machine? A local sequence counter ticks 0, 1, 2, … up to 4096, and that machine simply waits for the next millisecond if it somehow exhausts it.
There is no shared counter and no network call, yet collisions are structurally impossible — not merely improbable like v4, but ruled out by construction. And because the timestamp is the high-order bits, the IDs sort in roughly creation order, so they’re kind to your B-tree. That’s the trick: the machine id does the coordinating, once, at assignment time, instead of per-ID.
ULID and UUIDv7 (the ones to reach for today). Same idea, friendlier packaging: a millisecond timestamp up front, then random bits filling the rest.
ULID: 01ARZ3NDEK TSV4RRFFQ69G5FAV ← 48-bit time + 80-bit random, Crockford base32
UUIDv7: 0190e8a2-... (128 bits) ← 48-bit time + 74-bit random, drop-in UUID format
You get UUID-grade, coordination-free uniqueness (the random tail handles the same-millisecond
case via the birthday math), and time ordering for free (the timestamp prefix keeps inserts
sequential). UUIDv7 is the one I’d pick now for new work — it’s a proper standard, fits
anywhere a UUID fits, and fixes the exact index-fragmentation problem that made people afraid
of UUIDs in the first place. Laravel’s Str::orderedUuid() has quietly been doing this for
years.
The parts that bite
None of this is free, and the failure modes are worth naming so they don’t surprise you at 2am:
- Clocks that go backward. Time-based IDs assume time only moves forward. When NTP yanks the clock back — or a VM is restored from a snapshot — a Snowflake node can start minting IDs it already issued. Real generators refuse to go backward: they either wait for the clock to catch up or hard-error rather than risk a duplicate.
- Handing out machine ids. Snowflake’s guarantee rests entirely on every node having a unique machine id. Assign the same id to two boxes and the whole thing quietly breaks. So you’ve reintroduced a coordination problem — just a tiny, once-per-boot one (often solved with ZooKeeper, etcd, or a chunk of the private IP) instead of a per-ID one.
- Leaking information. A plain auto-increment tells the world things.
/orders/4001followed by/orders/4002says you did two orders, and lets anyone enumerate your data by counting up. Sequential IDs are an enumeration and business-intelligence leak. It’s why public-facing IDs are so often random-looking even when the internal key is a tidy integer.
The one idea underneath all of it
Step back and every scheme is doing the same thing: partitioning the ID space so that no two minters ever reach into the same slice. That’s the entire game, and once you see it you can classify anything:
| Scheme | Who owns which slice |
|---|---|
| Auto-increment | One counter owns all of it (that’s why it needs a lock) |
| Flickr ticket servers | Odd/even — each server owns half the number line |
| Hi/lo batching | Each server owns a block it was leased |
| Random UUIDv4 | Everyone shares the whole space, betting the space is too big to bump |
| Snowflake | The machine id field gives each node its own slice, per millisecond |
| ULID / UUIDv7 | Time slices it coarsely; randomness slices each millisecond finely |
Coordinate up front by dividing the space (Snowflake’s machine id, Flickr’s offsets), or make the space so vast that dividing it isn’t necessary (random UUIDs) — those are the only two moves. Everything else is packaging.
Why a working dev should care
You will almost never write an ID generator; the library already exists. But you choose one every time you make a table, and the choice has teeth:
- Reach for UUIDv7 / ULID as your default for anything distributed or public-facing. You get coordination-free uniqueness without the write-amplification tax that made people fear UUIDs — the best of both philosophies, and it’s a boring, standard choice now.
- Keep small integer keys for single-database, internal tables. Auto-increment is still the right tool when there is one authority and the IDs never leave the building. Don’t cargo-cult UUIDs onto a table that doesn’t need them and eat the index cost for nothing.
- Never expose a raw sequential ID on a URL or an API you don’t want enumerated. Either generate a random-looking public id, or accept that you’re publishing your growth rate and a map for scraping.
Two servers never picking the same ID isn’t luck and it usually isn’t a lock. It’s that someone, at some point, decided who owns which numbers — by time, by machine, by leased block, or by making the pool of numbers too vast to ever bump into yourself. Figure out which of those your ID scheme is doing, and you’ll know exactly how it fails before it does.