Build a Distributed ID Generator
A snowflake-style 64-bit ID generator that produces globally unique, time-ordered IDs without coordination, using a machine id and a sequence counter.
Project goal
Generate 64-bit IDs that are unique across machines without a central coordinator, roughly time-ordered so they sort by creation, and cheap to produce in a hot path.
Final features
- 64-bit globally unique IDs
- Time-ordered for sortability
- No coordination between machines
- Sequence for per-millisecond throughput
- Machine id assignment via config
Architecture
A 64-bit ID is a timestamp (41 bits), a machine id (10 bits), and a sequence (12 bits). The timestamp is milliseconds since an epoch; the machine id is assigned per process; the sequence increments within a millisecond and resets on the next. The generator is a single function that reads the clock and returns the next id.
Prerequisites
- Node.js 20 LTS or later
- A unique machine id per process (from config or environment)
Dependencies
- node:crypto built-in — Unique id generation
Directory structure
distributed-id-generator/
├── src/generator.ts
├── src/machine-id.ts
├── tests/generator.test.ts
└── package.json
Phases
1. Compose the ID
Combine the timestamp, machine id, and sequence into a 64-bit ID with bit shifts.
return (ts << 22) | (machineId << 12) | seq;
Expected behavior: The bit layout puts time in the high bits so IDs sort by creation.
2. Sequence per millisecond
Increment the sequence within a millisecond; on the next millisecond, reset it and update the timestamp.
if (now === lastTs) seq++; else { seq = 0; lastTs = now; }
Expected behavior: The sequence gives 4096 IDs per millisecond per machine.
3. Clock skew guard
If the clock reads before the last timestamp, throw or wait, because an earlier timestamp breaks uniqueness.
if (now < lastTs) throw new Error("clock moved backward");
Expected behavior: A backward clock skew reuses a timestamp and can collide IDs.
4. Machine id assignment
Assign a unique machine id per process from config, environment, or a coordination service.
const machineId = process.env.MACHINE_ID ?? await assignFromCoordination();
Expected behavior: Two processes with the same machine id can collide IDs.
Testing
Generate a million IDs and assert they are unique; generate on two machines with different ids and assert no collision; assert IDs from the same millisecond are sequential; assert a backward clock skew throws.
Troubleshooting
IDs collide
Two processes share a machine id; assign unique ids.
IDs are not sorted
The timestamp is not in the high bits; check the bit layout.
Deployment
Assign machine ids out of band, via environment variables or a coordination service; do not let two processes share an id.
Known limitations
- The 41-bit timestamp overflows in about 69 years from the epoch; a later epoch extends this.
- 4096 IDs per millisecond per machine is the cap; beyond that, the generator waits for the next millisecond.
- Clock skew is a hard failure; a large backward skew stops generation.
Project overview
A central counter that hands out IDs is a bottleneck and a single point of failure. A snowflake generator produces globally unique IDs without coordination, because each machine has a unique id and the ID encodes the machine, the time, and a per-millisecond sequence. Two machines never collide, because their machine ids differ, and one machine never collides with itself, because the sequence increments within a millisecond.
The 64-bit ID is a timestamp, a machine id, and a sequence, packed into 64 bits. The timestamp is 41 bits, which is about 69 years of milliseconds from a chosen epoch. The machine id is 10 bits, which is 1024 machines. The sequence is 12 bits, which is 4096 IDs per millisecond per machine. The layout puts the timestamp in the high bits, so IDs sort by creation time, which is useful for indexes and logs.
The sequence is what gives per-millisecond throughput. A machine that generates one ID per millisecond uses sequence zero every time. A machine that generates a thousand IDs in a millisecond uses sequences zero through 999, and they are all unique because the sequence differs. The sequence resets on the next millisecond, because the timestamp changed, so the IDs are unique across milliseconds even with the same sequence.
The clock skew guard is what keeps the generator correct. A clock that moves backward reuses a timestamp, and the sequence may overlap with a previous ID. The generator detects a backward clock and throws, because a silent collision is worse than a loud failure. A forward clock skew is fine, because a future timestamp is still unique.
The value of this project is the no-coordination, not the bit packing. Packing bits into 64 is trivial; producing globally unique IDs without a central counter is the part worth learning, because that is what lets a distributed system generate IDs in parallel without a bottleneck.
What you will build
The project delivers five capabilities, each driven by a test:
- 64-bit globally unique IDs
- Time-ordered for sortability
- No coordination between machines
- Sequence for per-millisecond throughput
- Machine id assignment via config
A 64-bit ID is a timestamp (41 bits), a machine id (10 bits), and a sequence (12 bits). The timestamp is milliseconds since an epoch; the machine id is assigned per process; the sequence increments within a millisecond and resets on the next. The generator is a single function that reads the clock and returns the next id.
Architecture and data model
The ID layout is a 64-bit integer with the timestamp in the high 41 bits, the machine id in the next 10 bits, and the sequence in the low 12 bits. The bit shifts are (ts << 22) | (machineId << 12) | seq, where 22 is 10 plus 12 and 12 is the sequence width. The layout puts time in the high bits so IDs sort by creation, which is the snowflake property.
The timestamp is milliseconds since a custom epoch, not the Unix epoch, because 41 bits from the Unix epoch overflows in 2039. A custom epoch of 2020 gives about 69 years, which is long enough for any current system. The epoch is a constant, not a configuration, because changing it changes the sort order of all IDs.
The machine id is 10 bits, which is 1024 machines. For more, widen the machine id and narrow the sequence, trading per-machine throughput for machine count. The machine id is assigned out of band, via an environment variable or a coordination service, and the generator trusts it is unique. Two processes with the same machine id can collide IDs, so the assignment is a deployment concern, not a generator concern.
The sequence is 12 bits, which is 4096 IDs per millisecond per machine. A machine that exceeds this waits for the next millisecond, because the sequence is exhausted. The wait is a spin or a sleep, and the generator returns the next ID in the next millisecond, which is a latency spike under sustained saturation.
The clock skew guard compares the current time to the last timestamp. If the current time is before the last, the generator throws, because a backward clock reuses a timestamp. If the current time is equal, the sequence increments. If the current time is after, the sequence resets and the timestamp updates. The guard is a hard failure, not a wait, because a large backward skew could wait indefinitely.
Prerequisites and setup
You need the following before you begin:
- Node.js 20 LTS or later
- A unique machine id per process (from config or environment)
The dependencies cover the core needs:
node:crypto(built-in) — Unique id generation
pnpm init
pnpm add -D typescript tsx vitest
Keep secrets and configuration in the environment, and validate them at boot so a misconfigured service fails loudly instead of silently dropping work.
Directory structure
distributed-id-generator/ is organized so each concern is testable in isolation:
distributed-id-generator/
├── src/generator.ts
├── src/machine-id.ts
├── tests/generator.test.ts
└── package.json
Phase 1: Compose the ID
Combine the timestamp, machine id, and sequence into a 64-bit ID with bit shifts.
return (ts << 22) | (machineId << 12) | seq;
The bit layout puts time in the high bits so IDs sort by creation.
The phase is driven by a test that asserts the expected behavior, so the implementation is correct before the next phase builds on it.
Phase 2: Sequence per millisecond
Increment the sequence within a millisecond; on the next millisecond, reset it and update the timestamp.
if (now === lastTs) seq++; else { seq = 0; lastTs = now; }
The sequence gives 4096 IDs per millisecond per machine.
The phase is driven by a test that asserts the expected behavior, so the implementation is correct before the next phase builds on it.
Phase 3: Clock skew guard
If the clock reads before the last timestamp, throw or wait, because an earlier timestamp breaks uniqueness.
if (now < lastTs) throw new Error("clock moved backward");
A backward clock skew reuses a timestamp and can collide IDs.
The phase is driven by a test that asserts the expected behavior, so the implementation is correct before the next phase builds on it.
Phase 4: Machine id assignment
Assign a unique machine id per process from config, environment, or a coordination service.
const machineId = process.env.MACHINE_ID ?? await assignFromCoordination();
Two processes with the same machine id can collide IDs.
The phase is driven by a test that asserts the expected behavior, so the implementation is correct before the next phase builds on it.
Testing
Generate a million IDs and assert they are unique; generate on two machines with different ids and assert no collision; assert IDs from the same millisecond are sequential; assert a backward clock skew throws.
The uniqueness test generates a million IDs and asserts they are all distinct, proving the sequence and the timestamp combine to prevent collision. The cross-machine test generates IDs with two different machine ids and asserts no collision, proving the machine id separates the two streams.
The sort test generates IDs across several milliseconds and asserts they are in ascending order, proving the timestamp in the high bits gives time ordering. The sequence test generates more than one ID in a millisecond and asserts they are sequential, proving the sequence increments within a millisecond.
The clock-skew test sets the clock before the last timestamp and asserts the generator throws, proving the guard detects backward skew. The overflow test generates 4096 IDs in a millisecond and asserts the next generation waits for the next millisecond, proving the sequence cap is enforced.
The bit-layout test generates an ID and extracts the timestamp, machine id, and sequence by bit masks, asserting they match the inputs. This pins the layout, because a wrong shift width would silently corrupt the fields.
The throughput test generates a million IDs and measures the time, asserting the generation cost is a few nanoseconds per ID, proving the generator is cheap enough for a hot path.
pnpm vitest run
Observability
Track the 64-bit globally unique ids rate, the id-generation resource usage, and the error count as the three primary signals for the distributed-id-generator system. A sudden change in any of these is the signal that something has shifted, not necessarily broken but worth a look before it becomes an incident. Chart the time-ordered for sortability alongside the 64-bit globally unique ids so a correlation is visible at a glance, because a rising time-ordered for sortability with a stable 64-bit globally unique ids means the system is doing more work per unit, not that it is failing.
Log every compose the id transition with the context that triggered it, because an operator who sees the symptom but not the cause will guess wrong. The log entry should carry the id-generation identifier, the input that caused the transition, and the outcome, so the operator can reconstruct what happened without re-running it. A log without context is a line of text; a log with context is a diagnosis.
Set alerts on the derived metrics, not the raw counters. A rate of change is more actionable than a total, because a total grows monotonically and never tells you when the growth accelerated. Alert on the 64-bit globally unique ids error rate crossing a threshold, the id-generation utilization exceeding a percentage, and the time-ordered for sortability latency exceeding the SLO. Each alert should have a runbook link, so the operator who receives it at three in the morning knows what to do, not just what is wrong.
The distributed-id-generator system should expose its metrics in a format the existing dashboard can scrape, so the observability is additive, not a new tool. A metric that nobody reads is noise, so before adding one, confirm it has a consumer. The dashboard has three panels: throughput, error rate, and resource utilization, each with a threshold line that is the SLO, so a glance tells an operator whether the system is healthy without reading numbers.
Deployment and operations
Assign machine ids out of band. The simplest assignment is an environment variable per process, set by the deployment to a unique value. A coordination service, such as a database or a key-value store, can assign ids dynamically, but that adds a startup dependency and a failure mode. The environment variable is the common case.
Do not let two processes share a machine id. The generator does not detect a collision, because it has no way to, so two processes with the same id will produce overlapping IDs silently. The deployment must guarantee uniqueness, typically by assigning a range of ids to each host and letting each process on the host take a different one.
Choose the epoch once and never change it. Changing the epoch changes the sort order of all IDs, because the timestamp is relative to the epoch. A new epoch makes new IDs sort before old IDs, which breaks any index that relies on ID order.
Monitor the generation rate and the wait events. A high wait rate means the sequence cap is being hit, and the machine needs a wider sequence or a lower rate. A clock skew event is an alert, because it means the generator stopped, and the cause is a system clock problem, not a generator bug.
Do not use the ID as a secrecy token. The ID is predictable, because the timestamp and the machine id are observable, so an attacker can guess IDs. The ID is for uniqueness and ordering, not for unguessability.
Security considerations
Treat every input to the distributed-id-generator system as untrusted, because the 64-bit globally unique ids path is the one place an external caller controls what the system processes. Validate the input shape before any id-generation operation, and reject early with a clear error rather than letting a malformed input propagate into the compose the id phase where it causes a confusing failure that is hard to trace back to its source.
Keep secrets and configuration in the environment, never in the repository. The node:crypto credentials, the signing keys, and the service endpoints are all environment variables, read once at boot and validated, so a misconfigured distributed-id-generator fails loudly at startup instead of silently misbehaving at runtime. A system that starts with a bad configuration and runs anyway is a system that will produce wrong results for hours before anyone notices.
Rate-limit the 64-bit globally unique ids endpoint per caller, because an unbounded id-generation operation is a resource leak as much as a security problem. A caller that can trigger the sequence per millisecond path without limit can exhaust the system, whether intentionally or not. The rate limit is a per-caller ceiling that costs legitimate callers nothing and stops the abuse before it reaches the compose the id logic.
Audit-log every state-changing operation with the caller identity, the input, and the timestamp. The audit log is not for debugging, it is for accountability: if something went wrong, the log says who did what and when. The distributed-id-generator system writes the audit log to an append-only store that the system itself cannot modify, so a compromised system cannot rewrite its history.
Do not expose internal errors to the caller. A stack trace is a map of the system internals, and an attacker who collects them learns the id-generation structure. Return a generic error with a correlation id, and log the full error internally with the same id, so an operator can trace it without leaking it.
Limitations and next steps
- The 41-bit timestamp overflows in about 69 years from the epoch; a later epoch extends this.
- 4096 IDs per millisecond per machine is the cap; beyond that, the generator waits for the next millisecond.
- Clock skew is a hard failure; a large backward skew stops generation.
The 41-bit timestamp overflows in about 69 years from the epoch. A later epoch extends this, but the epoch is fixed at deployment, so a system that outlives 69 years needs a new ID format. This is far enough that it is not a practical concern, but it is a real limit.
4096 IDs per millisecond per machine is the cap. Beyond that, the generator waits for the next millisecond, which is a latency spike. A wider sequence raises the cap but narrows the machine id, trading machine count for per-machine throughput. The right trade depends on the deployment.
Clock skew is a hard failure. A large backward skew stops generation, because the guard throws. A small backward skew, such as a few milliseconds, could be handled by waiting, but this build treats any backward skew as an error, because a wait could be indefinite and a silent collision is worse than a loud failure.