0% completed
Unique ID Generator: ID Generation Strategies
On This Page
Step 6: Detailed Component Design and ID Generation Strategies
6.1 Comparing ID Generation Approaches
6.2 Snowflake ID Algorithm Design
6.3 Opaque ID (UUID) Generation Design
6.4 Data Storage and State Management
6.5 Failure Recovery Details
Step 6: Detailed Component Design and ID Generation Strategies
Step 5 built the service around the generation routine but never described the routine itself. This step describes it. We compare the ways to make a unique ID, then design the two we chose.
The choice of algorithm decides most of what follows. It sets how many IDs one node can make, whether they sort by time, and what has to be coordinated.
6.1 Comparing ID Generation Approaches
Every scheme answers one question: where does uniqueness come from? There are only three answers. It comes from a space too large for collisions, from one central counter, or from structure built into the ID itself. The approaches below are variations on those three.
1. UUID: random 128-bit IDs. A UUID version 4 is 16 random bytes, and that is the whole idea. Any node can make one on its own, with no coordination and no shared state. The chance that two collide is so low it can be ignored. Uniqueness comes from the size of the space, not from any agreement between nodes. That is why UUIDs are so common in distributed systems.
A UUID v4 is also opaque. Nobody can read a time or a sequence out of it.
The cost is size and order. A UUID is 128 bits, twice the size of a 64-bit number. That extra size shows up in storage and in every index that holds the key. So the cost multiplies with the number of indexes rather than being paid once. UUIDs also do not sort by creation time. Newer versions like UUIDv7 add a timestamp, but v4 is fully random. Where order or a small key matters, UUID v4 is a poor fit.
2. Database auto-increment, or one central counter. The simplest approach is one counter in one place. For example, a single row in a SQL database. Each request runs an UPDATE that adds one and returns the new value. Uniqueness and strict order both come out of that, with nothing else to build. Some systems have used it, either as a dedicated ID server or as a table.
The problem is scale. Every request updates the same row on the same primary node. At 100k IDs per second, one database sequence is unlikely to keep up. The transaction log and the row lock are the limit. Even if it kept up, that database is a single point of failure: one part whose loss stops the whole feature. When it is down, no IDs at all can be created. And every ID costs a database write, so latency is high.
So this approach fails two requirements at once, throughput and availability. There is one way to rescue it. Hand out ranges of numbers ahead of time, so most requests never reach the database. That is the next approach.
3. Segment (batch) allocation from a database. This is the compromise. A database still owns the counter, but it hands out blocks of IDs, called segments, to each generator node. A node asks: "give me the next 10,000 IDs". The database adds 10,000 to its counter in one atomic step, one that cannot be interrupted halfway. It returns a range, say 1,000,000 to 1,009,999. The node then serves those IDs from memory. It only goes back to the database when the block runs out.
This cuts database load to one query per 10,000 IDs instead of one per ID. Throughput scales much better, and most requests are answered from memory.
Two costs remain. First, the central store is still critical. If it is down, or a network partition cuts it off, nodes cannot refill. Once every node uses up its block, the whole service stalls until the store returns. Second, blocks are wasted. If a node crashes with half its block unused, those IDs are never issued. Reissuing them would risk duplicates, so we abandon them. That waste is usually acceptable in exchange for simplicity.
Order is strict within one counter, since every ID comes from it in sequence. But blocks held by different nodes interleave in time. Node A holds 1 to 10,000 and node B holds 10,001 to 20,000. Node B may issue some of its IDs before node A finishes its block. So the order of creation across nodes is only approximate. Each node is in order within its own block.
4. Distributed ID services: Snowflake and its variants. Twitter's Snowflake was built for high scale, and it removes the central step completely. Uniqueness is built into the ID itself instead. A 64-bit Snowflake ID is made of a timestamp, a machine identifier, and a sequence number. Any node can generate IDs on its own, as long as its machine ID is unique and its clock is in sync. Twitter's original layout used 41 bits for time, 10 bits for the machine, and 12 bits for the sequence. The 10 machine bits were often split into 5 for the datacenter and 5 for the node. That layout gives:
- 2^41, about 2.2 trillion timestamp values, which is enough for 69 years at millisecond precision.
- 2^10 = 1024 unique machine IDs, so up to 1024 nodes can generate at the same time.
- 2^12 = 4096 IDs per node per millisecond.
This is very fast. One node can produce 4096 IDs in one millisecond before it must wait for the next. That is about 4 million IDs per second on one machine, far above our need. Twitter rarely hit that limit, but the spare capacity is there.
Snowflake IDs are time-sortable. Within one node the order is exact, by time and then by sequence. Across nodes, a slightly slow clock can put two IDs a little out of order. That is the one promise this format cannot make across the whole fleet.
The costs are coordination and clocks. Someone must assign each machine ID exactly once, and every clock must be reasonably in sync. There is also a predictability issue. Because IDs grow with time and sequence, an observer can estimate when an ID was made and how busy the system is. Snowflake IDs are not cryptographically random, so this matters for security. Twitter used ZooKeeper to assign machine IDs and to handle clock problems. If a clock went backward, Snowflake would stop generating IDs until it recovered, to avoid duplicates.
Despite that, Snowflake scales well and has no single point of failure in the generation path. Nodes work on their own. Instagram, Discord, and many others have adopted similar schemes, sometimes with different bit splits.
5. Other approaches. MongoDB's ObjectID is a 96-bit ID: a 4-byte timestamp in seconds, a 5-byte random host identifier, and a 3-byte counter. It is larger than 64 bits and only roughly ordered, since seconds are coarser than milliseconds. CUID and NanoID are random string IDs, usually made on the client, and often used for front-end or offline creation. They aim at low collision at modest scale and can be long strings. UUIDv7 is a 128-bit time-ordered UUID. At our scale, none of these are as common in backend distributed systems as Snowflake or UUID. Length or throughput rules them out.
Why Snowflake plus UUID, a hybrid? No single scheme meets every requirement, so we use two.
For time-sortable IDs we use the Snowflake style. It gives ordered, unique IDs at massive scale. A 64-bit ID is cheap to store and send, and it works well as a database key. Generation is spread across nodes and very fast, which is what more than 100k IDs per second needs. The two costs, machine ID assignment and clock sync, are trade-offs we can manage. So this covers the functional need for time-sortable IDs, and the non-functional needs for throughput and partition tolerance. There is no central bottleneck.
For opaque IDs we use a secure random 128-bit ID, which is a UUID v4. Some IDs must carry no time and no order. A well-known UUID method gives near-certain uniqueness with no coordination. Every node makes opaque IDs on its own, and the chance that two nodes collide is low enough to ignore. These IDs are also non-sequential and unguessable, so they are safe to expose in URLs or as API keys.
Both formats can come from the same service. One generator node has two code paths, one for Snowflake IDs and one for UUIDs. We run one system and support both. Many real systems do this: time-based IDs inside, opaque IDs for external clients.
6.2 Snowflake ID Algorithm Design
For time-sortable IDs we follow the Snowflake format with a few changes of our own.
Each ID is a 64-bit unsigned integer. We leave the most significant bit, bit 63, at zero, and Snowflake does the same. That way the ID stays positive in a signed 64-bit type like a BIGINT. The choice costs one bit and keeps every sort and range comparison working. The other 63 bits split into three fields.
Timestamp, 41 bits. This counts milliseconds since a custom epoch, that is, a start time we pick rather than 1970. The counter starts at zero on that date, so a recent epoch gives us the full 69 years from the day we deploy. Twitter picked November 2010 for that reason. 41 bits hold about 2.2 trillion milliseconds, roughly 69 years. Because time sits in the highest bits, IDs grow as time passes. That placement is also what makes them sort by time.
Region + machine ID, 10 bits. We split these 10 bits into a region identifier and a machine identifier within that region. One split is 5 bits for region (32 regions) and 5 for machine (32 nodes per region). Another is 4 bits for region (16 regions) and 6 for machine (64 nodes each). The choice depends on the deployment. For us, 32 regions is plenty. Even very large systems have a few dozen datacenters.
This field is what keeps IDs from two nodes distinct, even when their clocks and sequences overlap. The exact split matters less than one rule: the 10-bit value must be unique per live generator node. The coordinator service assigns these values, and that is what guarantees it. If we ever needed more than 1024 nodes, we would widen the field or add a second tier of coordination. Given the throughput of one node, 1024 should be enough.
Sequence, 12 bits. This is a counter that separates IDs made by one node within the same millisecond. It goes up by one per ID and resets to 0 when the millisecond changes. With 12 bits, a node can make 2^12 = 4096 IDs in one millisecond before the counter overflows.
If a node hits that limit, it has tried to make more than 4096 IDs in one millisecond, over 4 million per second. The algorithm then blocks until the next millisecond tick and continues from sequence 0. This wait keeps IDs unique, because a sequence number is never reused within one timestamp. In practice 4096 per millisecond is a huge rate, so this will be rare. That wait is also the only way this service can be slow under load. So if nodes hit the ceiling often, that is the signal to add generator nodes and spread the load. Another option is to split one node's load into several processes with distinct machine IDs.
The diagram below shows how the three fields fit into the 64 bits.
The diagram shows the layout. Written out in order, a 64-bit ID in binary looks like:
[timestamp: 41 bits][region+machine: 10 bits][seq: 12 bits]
Now put real values in it. Suppose the 41-bit timestamp since the epoch is 101010... (some value). Region ID is 3, which is 00011 in 5 bits. Machine ID is 5, which is 00101 in 5 bits. Sequence is 37, which is 0000 0010 0101 in 12 bits. Joining those bits gives the 64-bit number.
A real example: Snowflake ID 1922298559865028608 is a tweet ID. It breaks down into timestamp, machine, and sequence exactly as documented. Anyone who knows the layout can pull the time and other fields out of the ID. That is why we call this format non-opaque. It still serves ordering and scale well, so we accept what it reveals.
Machine ID coordination. The 10-bit field only works if no two live nodes hold the same value. Something has to guarantee that, so we run a ZooKeeper cluster (or a similar coordinator) that owns the 10-bit machine IDs. When a generator node starts, it creates an ephemeral node in ZooKeeper, like /id-generators/<region>/<machine_id>. An ephemeral node is an entry that disappears when the session that created it ends. ZooKeeper can assign the number, or the node can try IDs in order until one is free. Or the node states its region and asks for a free slot in it. For example, region 1 nodes might use IDs 0 to 31. Because ZooKeeper tracks live ephemeral nodes, two live nodes never hold the same ID.
Notice when the coordinator is involved. It is used once, at startup, and never on the path of a single ID.
When a node dies, its ephemeral entry is removed and its machine ID returns to the pool. Reuse needs care. The dead node no longer generates IDs. So reuse is safe if the new node's clock is at or past the old node's last timestamp. If the new clock is behind, the pair could produce the same ID.
That is why we do not hand the number out again immediately. We hold it back for a short buffer, longer than the clock error we accept, or record the old node's last timestamp. The buffer costs one number out of 1024 and removes the failure. Clock issues are covered next.
If ZooKeeper itself fails, existing nodes keep generating with the IDs they already hold. Nothing changes for them. What stops is starting new nodes or reassigning IDs, until ZooKeeper is back. To limit that, we run ZooKeeper as a 3-node ensemble across servers, and possibly across regions. The generator rarely changes state after startup, so short ZooKeeper outages barely matter.
Clock synchronization and skew. Every Snowflake-like system needs clocks that move forward and stay close to each other. All generator nodes run NTP (Network Time Protocol) daemons to keep clocks accurate to a few milliseconds. Small differences between nodes only mean that one node's IDs may look slightly out of order against another's. They never cause duplicates.
The real risk is a clock that jumps backward. An NTP correction or a paused VM can do that. Suppose node A's clock goes back 100 ms. Its timestamps now repeat a range it has already used, with the same machine ID and a reset sequence. That is exactly how a duplicate ID is made. To prevent it:
- Each node remembers the last timestamp it used. If the current time is earlier than that, the generator pauses until the clock passes it again. For small skews the pause is short. For a large jump, the node is stalled for that long, and an operator alert may be needed. Either way, it never issues an ID that could repeat an old one.
- Large backward jumps are rare when NTP is set to adjust in small steps. If one does happen, say someone reset the clock by hand, the safe move is to take the node out of service and restart it with a new machine ID. The node then starts a fresh timeline that cannot overlap its past.
- The last timestamp lives only in memory. A restart erases it, so a restarted node with a slipped clock could reuse a millisecond. We could save the last timestamp to disk on shutdown and refuse to start if the clock is behind it. Since we prefer stateless nodes, a runtime check plus the reuse buffer above is enough for us.
- A clock that jumps forward is less dangerous. The IDs get large timestamps, but they are still unique. It does create a gap in ordering against other nodes. If the clock later returns to normal, later IDs from that node will have lower timestamps than the ones made during the jump. Those jump IDs look like outliers in sort order, but they stay unique. NTP normally smooths adjustments, so this is unlikely. In extreme cases, an operator can configure NTP to step slowly, or restart the service after the correction.
Concurrency and throughput on a node. The algorithm on one node is small. Read the clock in milliseconds. If it equals the last time, add one to the sequence. If it is a new time, reset the sequence to 0. Then pack the bits into the ID. That is a few CPU instructions. On a multi-threaded server we make it safe for calls that arrive at the same time:
- Guard the timestamp and sequence with a lock, or with an atomic compare-and-swap. The overhead is tiny. Some implementations instead use one thread that generates IDs in order and feeds a queue. Either way, a locked section with two comparisons and an increment handles millions of operations per second on a modern CPU. Our target of about 100k per second per node is only 100k lock operations per second, which is trivial.
- If one node must go faster, we could split the sequence range between threads. Thread 1 uses 0 to 2047 and thread 2 uses 2048 to 4095 within the same millisecond. That adds complexity we do not need. With many nodes available, we scale out instead of tuning one node to its limit.
- The node runs as a stateless service, likely an HTTP or RPC server. Each request runs the generation routine, which takes microseconds. Most of the request latency is therefore network time, not generation time. One thread can serve many requests per second. The service could batch internally, but one ID per request is fine at this throughput.
Integration with the API. The node formats the 64-bit ID into the response. Many systems return Snowflake IDs as decimal strings. Twitter exposes tweet IDs that way. We can do the same, or return a JSON number. Clients in languages with 64-bit integers can treat it as an integer, and databases can store it as a BIGINT. JavaScript is the exception. It stores numbers as floats and loses precision above 53 bits. An ID ending in 608 can silently come back ending in 600. Nothing raises an error, which is what makes that dangerous. So JavaScript clients must receive the ID as a string. It is an API detail, but it matters in implementation.
6.3 Opaque ID (UUID) Generation Design
The opaque ID design is simpler, because there is nothing to coordinate.
We use 128-bit UUID version 4 generation. That means 16 random bytes from a CSPRNG, a cryptographically secure pseudo-random number generator. We set the version and variant bits that UUID v4 requires, which mark the value as random. The result looks like f47ac10b-58cc-4372-a567-0e02b2c3d479, the canonical 8-4-4-4-12 hex format with hyphens. We could also return 32 hex characters without hyphens. The format does not change uniqueness.
Two random 128-bit values almost never collide. We treat it as impossible in practice. One cited estimate: generating 1 billion random UUIDs per second for 100 years gives about a 50% chance of a single collision. Our system makes maybe 10^10 per day, nowhere near that. So we do no coordination and no duplicate check. The size of the space is the guarantee. What is left to get right is the randomness source. Most languages provide a secure RNG, or a library call that returns a UUID directly.
Making a random 128-bit number is very fast. Formatting it as a string with hyphens costs more, and that is still trivial at our scale. Modern CPUs make millions of random numbers per second. Even a lock around one RNG handles 100k per second. If needed, thread-local RNG instances remove the contention. A secure RNG is a little slower than a plain one, but still microseconds per ID.
Each call is independent. Nothing is remembered between calls, so any node can answer an opaque-ID request at any time. This mode does not need the coordinator at all. There is no sequence and no time to manage. So none of the clock and machine ID failures from 6.2 can reach this path.
The security property is why we offer this format at all. The ID means nothing to an outsider. It reveals neither the time nor the origin. That is what we want when IDs appear in URLs or API responses. Users must not learn how many items exist or when one was created. If user IDs were sequential, an attacker could step from one valid ID to the next and scrape records. Random IDs make that search hopeless. The trade is that you cannot sort by creation time without a separate timestamp field.
The service returns the opaque ID as a string in UUID format, for example {"id": "de305d54-75b4-431b-adb2-eb6b9e546014"}. Clients treat it as an opaque token. If they need creation time, they store it separately, because the ID does not carry it. We could use UUIDv7 or ULID for a little ordering, but both leak time and would no longer be opaque. So the opaque variant stays purely random. Our advice to clients: use time-sortable IDs when you need order, and opaque IDs when you need unguessable values.
6.4 Data Storage and State Management
The design keeps persistent storage to a minimum. One decision explains the rest: uniqueness here is computed, never looked up.
So we do not store issued IDs in any database or log, beyond short-lived debug logs. Storage does not grow with the number of IDs. There is no distributed database or cache to run. Uniqueness comes from the structure of the ID, so there is nothing to check against. That matters for scale. Inserting 100k keys per second into a store would be the largest write load in the system, for no gain. Uniqueness is designed into the format instead, by time plus machine plus sequence, or by the size of the random space.
The only real state lives in the coordinator, ZooKeeper or etcd. It holds one small znode per active worker, a few bytes each, with perhaps the worker's address or ID. That is at most about a thousand entries, which is tiny. ZooKeeper keeps this in memory plus a small transaction log on disk.
This state must survive, so a full restart never assigns one machine ID twice. Since it is a few entries, backup and reload are easy. If the coordinator data were lost and every node restarted, we could briefly risk duplicate assignments. To prevent that, we would either configure IDs by hand in that scenario, or require coordinator recovery first. So we run the coordinator with disk persistence, and we may keep a mapping record alongside the ephemeral nodes.
We log operations, and we may keep a short in-memory audit window of recent IDs to catch anomalies like a duplicate. Logging every ID to disk is not practical. Instead we log counts per second and notable events, like a sequence rollover or a clock going backward. These logs help debugging. They are not part of the data path.
The service could also offer a method to parse or validate an ID: confirm the format, and perhaps decode the timestamp. That needs no stored data. It is pure arithmetic on the bits. For a Snowflake ID, shift right by 22 bits and add the epoch to recover the time. This would be a small utility API, not a major part.
6.5 Failure Recovery Details
Generator node failure. If a generator instance crashes or is removed, the IDs it already issued stay valid. They are just numbers, already in use by clients. Nothing has to be recovered, because the node was not holding work for anyone. The coordinator notices the lost session, since the ephemeral znode is gone, and frees that machine ID. An orchestrator like Kubernetes or an auto-scaling group starts a replacement. The new instance registers and may receive the same machine ID, if it is the next free one in that region.
If it does, we rely on the clock handling from 6.2. We assume the new clock is current. If the old instance's clock ran slightly ahead, the new one may produce IDs a little lower than the old node's last ones. Those values were never generated before, and the old node is offline, so there is still no duplicate. To be extra safe, a restarting node could take a different machine ID from the one that just failed. That uses up ID space unless managed, and with synchronized clocks it is not a major concern.
Datacenter outage. If a whole region goes down, its nodes stop generating. Clients fail over. For example, the load balancer routes to a secondary region. Region is encoded in every ID, so IDs from another region are still unique. Regions do not depend on each other, so the rest continue normally.
When the region returns, its nodes restart with the current time. Their new IDs are above anything they made before, because the timestamp is later. A long outage just means that region produced no IDs for a while. That is fine. IDs do not need to be contiguous, so there is no "hole" problem. Uniqueness is enough.
ZooKeeper failure. If the coordinator cluster fails completely, no new node can join. Existing nodes keep generating with the machine IDs they hold. One caution: a node that loses its session during a long outage might stop, if it was written to require a live ZooKeeper session. So we can design the generator to keep using its last known ID while ZooKeeper is unreachable, as long as it detects no conflict. It reconnects and reconciles when ZooKeeper returns.
Because ZooKeeper manages node IDs and not requests, even a slow ZooKeeper does not touch generation throughput. At runtime the system is almost fully decoupled from it, especially once every node has its ID. This is an engineering detail, but it keeps availability high.
Duplicate ID safeguards. The logic guarantees uniqueness in theory. In debug mode we can add runtime checks. A node might keep a sliding window of recent IDs, that is, the last N it produced, or just the last one. It then asserts that each new time-sortable ID is larger, or that each new random ID differs from the last. This is only to catch bugs. In production, storing a million recent IDs per node is too much, but storing the last one or the last timestamp is fine. For the rest we rely on the math.
Scaling beyond 1024 nodes. If we ever needed more than 1024 generator nodes at once, or more than 32 regions, we have options:
- Give more bits to the machine ID. Taking them from the timestamp shortens the time range. Taking them from the sequence lowers the per-node rate. For example, 12 machine bits and 10 sequence bits allow 4096 nodes, but only 1024 IDs per millisecond per node. With that many nodes, per-node throughput is not the limit, so the trade works.
- Deploy a second independent Snowflake service with a different epoch or a different ID namespace, like a prefix. That pushes work onto clients, which must know which service to call.
- Each node can make millions of IDs per second, so more than 1024 active nodes is unlikely. It would only happen if nodes were limited by something else, like network or CPU. 1024 nodes making millions each is far above our 100k per second target.
So the detailed design runs two algorithms in one service. Snowflake gives the time-sortable, high-throughput IDs, and a UUID-style random ID gives the opaque ones. Together they cover both functional requirements. What makes them safe is care in three places: the bit layout, the machine ID assignment, and the clock behavior. Snowflake and its derivatives are proven in production at Twitter and elsewhere, and UUIDs give the opaque path a well-understood standard.
Next: Step 7, which scales the chosen approach.
Reading Progress
0%
On This Page
Step 6: Detailed Component Design and ID Generation Strategies
6.1 Comparing ID Generation Approaches
6.2 Snowflake ID Algorithm Design
6.3 Opaque ID (UUID) Generation Design
6.4 Data Storage and State Management
6.5 Failure Recovery Details