System Design ·

Designing a Distributed ID Generator: UUIDs, Snowflake IDs, and Coordination-Free Sequences at Scale

A deep dive into generating unique identifiers in distributed systems. Covers UUID v4 and v7, Twitter's Snowflake approach, ULIDs, and coordination-free strategies, with TypeScript implementations, tradeoff comparisons, and production considerations for clock skew and multi-region deployments.

Designing a Distributed ID Generator: UUIDs, Snowflake IDs, and Coordination-Free Sequences at Scale

Every distributed system eventually hits the same wall: you need a unique identifier, and you need it now, without talking to anything else. The naive solutions break in ways that are subtle and expensive to debug in production. Auto-increment sequences require a central coordinator. UUIDs don’t sort well in B-tree indexes. Snowflake IDs depend on synchronized clocks. Each choice carries assumptions about your deployment topology that will surface at the worst possible time.

This article is a systematic walk through the options: what each approach gives you, where it breaks, and how to make an informed choice for your system.

The Core Problem

A useful unique ID has several properties, and they conflict with each other:

Uniqueness: No two nodes ever generate the same ID. This sounds obvious, but it is surprisingly easy to violate in practice (reused machine IDs, clock rollbacks, sequence counter overflows).

Monotonicity: IDs generated later should sort after IDs generated earlier. This matters enormously for database write amplification. Random IDs scattered across a B-tree cause page splits on every insert. Sequential IDs amortize that cost across far fewer splits.

Coordination-free generation: The node generating the ID should not need to contact any other service. Every round-trip you add to ID generation becomes a latency floor for every write in your system.

Compactness: 128 bits is twice the storage of 64 bits. At 100 million rows, that is 800 MB of extra index storage just for primary keys.

No scheme satisfies all four simultaneously. Understanding what you are giving up is the whole job.

UUID v4: Globally Unique, Structurally Chaotic

UUID v4 is 122 bits of randomness with 6 bits reserved for version and variant markers. It requires no coordination, has astronomically low collision probability (you would need to generate 2.71 quintillion UUIDs to reach a 50% collision probability), and is available natively in Node.js.

import { randomUUID } from "crypto";

// Built-in since Node 14.17
const id = randomUUID();
// "f47ac10b-58cc-4372-a567-0e02b2c3d479"

The problem is what happens to your database. UUID v4 values are uniformly distributed across the 128-bit space, which means each new insert lands at a random position in a B-tree index. Every insert potentially causes a page split. Index pages stay partially empty, wasting buffer pool capacity. For write-heavy workloads on large tables, this shows up as sustained high write latency and ballooning storage.

The other cost is human ergonomics. UUIDs are 36 characters when formatted. They are not sortable by time. Log correlation across services requires careful tooling to be usable.

Where UUID v4 fits: any system where you need globally unique IDs across independent databases that will never coordinate, and write volume is low enough that index fragmentation is not a concern. External-facing IDs in APIs are a common use case, since the randomness of v4 provides mild obscurity (it does not leak sequence or timestamp information).

UUID v7: The Upgrade You Probably Want

UUID v7 was ratified in RFC 9562 (May 2024). It is a time-ordered UUID with a 48-bit Unix timestamp in milliseconds at the high bits, followed by 12 bits of sub-millisecond precision or sequence data, and 62 bits of randomness.

// No native Node.js support yet; use the uuid package
import { v7 as uuidv7 } from "uuid";

const id = uuidv7();
// "018f2a3b-4c5d-7e6f-8a9b-0c1d2e3f4a5b"
// First 48 bits encode millisecond timestamp
// Lexicographic order == chronological order

function extractTimestamp(uuidV7: string): Date {
  const hex = uuidV7.replace(/-/g, "");
  const timestampHex = hex.slice(0, 12);
  const timestampMs = parseInt(timestampHex, 16);
  return new Date(timestampMs);
}

UUID v7 maintains the coordination-free generation of v4 while adding the time-prefix monotonicity that databases need. The lexicographic sort order matches chronological order, which means B-tree inserts cluster near the index tail. Write amplification drops to roughly the same level as a sequential integer sequence.

The 62 bits of randomness still provide near-zero collision probability without coordination. The tradeoff is that you are now embedding a timestamp in the ID, which leaks approximate creation time to anyone who has the ID. For many systems that is acceptable. For systems where IDs are externally visible and timing should be opaque, it is worth considering.

UUID v7 is the default choice for new systems today unless you have a specific reason to deviate.

Snowflake IDs: Compact, Sortable, Clock-Dependent

Twitter open-sourced the Snowflake format around 2010. The layout is:

| 1 bit (sign) | 41 bits (ms timestamp) | 10 bits (machine ID) | 12 bits (sequence) |

This produces a 64-bit integer that is sortable by time, compact (8 bytes vs 16 for UUID), and embeds enough entropy per millisecond (4,096 IDs per machine per millisecond) that collision is effectively impossible under normal operation.

class SnowflakeGenerator {
  private readonly epoch = 1_700_000_000_000n; // Custom epoch: 2023-11-14
  private readonly machineId: bigint;
  private sequence = 0n;
  private lastTimestamp = -1n;

  constructor(machineId: number) {
    if (machineId < 0 || machineId > 1023) {
      throw new Error("Machine ID must be between 0 and 1023");
    }
    this.machineId = BigInt(machineId);
  }

  generate(): bigint {
    let timestamp = BigInt(Date.now()) - this.epoch;

    if (timestamp < this.lastTimestamp) {
      // Clock moved backward: options are throw, wait, or borrow from sequence
      throw new Error(
        `Clock moved backward by ${this.lastTimestamp - timestamp}ms`
      );
    }

    if (timestamp === this.lastTimestamp) {
      this.sequence = (this.sequence + 1n) & 0xfffn;
      if (this.sequence === 0n) {
        // Sequence exhausted within this millisecond: spin-wait for next ms
        while (timestamp <= this.lastTimestamp) {
          timestamp = BigInt(Date.now()) - this.epoch;
        }
      }
    } else {
      this.sequence = 0n;
    }

    this.lastTimestamp = timestamp;

    return (timestamp << 22n) | (this.machineId << 12n) | this.sequence;
  }

  // Decode for debugging
  decode(id: bigint): { timestamp: Date; machineId: number; sequence: number } {
    const timestamp = (id >> 22n) + this.epoch;
    const machineId = Number((id >> 12n) & 0x3ffn);
    const sequence = Number(id & 0xfffn);
    return {
      timestamp: new Date(Number(timestamp)),
      machineId,
      sequence,
    };
  }
}

The 41-bit timestamp gives you 69 years from the custom epoch before the counter wraps. Choosing a recent custom epoch is worth doing: setting it to Unix epoch wastes bits and limits your range unnecessarily.

Where Snowflake breaks down: machine IDs. You have 1,024 slots (10 bits). Assignment requires coordination. In Kubernetes or auto-scaling environments, you need a mechanism to allocate and reclaim machine IDs without overlap. Common approaches include: stateful assignment via ZooKeeper or etcd, database-backed atomic counters, and pod ordinal suffixes in StatefulSets. Each adds operational complexity. If you get machine ID assignment wrong, you get collisions with no early warning.

The harder problem is clock skew. NTP adjustments can move a system clock backward. When that happens, the naive implementation throws an error (as shown above), which is often the right call. Discord’s implementation waits for the clock to catch up. Neither approach is uniformly correct: the choice depends on your tolerance for latency spikes vs. errors under clock events.

ULID: Lexicographically Sortable, URL-Safe

ULID (Universally Unique Lexicographically Sortable Identifier) is a 128-bit format with a 48-bit timestamp in the high bits and 80 bits of randomness, encoded as a 26-character Crockford Base32 string.

import { ulid, decodeTime } from "ulid";

const id = ulid();
// "01ARZ3NDEKTSV4RRFFQ69G5FAV"
// Lexicographic order == chronological order

const timestamp = decodeTime(id);
// 1469918176385 (Unix timestamp in ms)

// ULID is also usable as a UUID-compatible binary
// The first 10 chars encode the timestamp

ULID’s main advantage over UUID v7 is the encoding: 26 characters vs 36, no hyphens, URL-safe, case-insensitive. The binary layout is almost identical to UUID v7 (timestamp high bits, randomness low bits), so the database performance characteristics are the same.

The tradeoff is that ULID is not a standardized RFC format. Libraries vary in behavior, particularly around monotonicity within the same millisecond. Some implementations increment the random portion for same-millisecond IDs; others generate fresh randomness. Inconsistent behavior across services in the same system will give you non-monotonic IDs within a millisecond window.

Database Sequences: Still Viable for Some Workloads

PostgreSQL sequences are serializable, strictly monotonic, and require no application-level logic. The cost is what it has always been: a central point of coordination.

// PostgreSQL: use BIGSERIAL or an explicit sequence
// CREATE TABLE events (
//   id BIGSERIAL PRIMARY KEY,
//   ...
// );

// For distributed generation, use a sequence server pattern:
// Each app node pre-fetches a range of IDs
class SequenceCache {
  private rangeStart = 0;
  private rangeEnd = 0;
  private readonly batchSize = 1000;

  async next(db: DatabaseClient): Promise<number> {
    if (this.rangeStart >= this.rangeEnd) {
      await this.fetchRange(db);
    }
    return this.rangeStart++;
  }

  private async fetchRange(db: DatabaseClient): Promise<void> {
    // Atomically reserve a range in the database
    const result = await db.query<{ next_val: number }>(
      `SELECT nextval('event_id_seq')
       FROM generate_series(1, $1)
       ORDER BY 1 DESC
       LIMIT 1`,
      [this.batchSize]
    );
    this.rangeStart = result.rows[0].next_val - this.batchSize + 1;
    this.rangeEnd = result.rows[0].next_val + 1;
  }
}

The batched sequence pattern reduces coordination overhead significantly: at 1,000 IDs per batch, you are making one database round-trip per thousand writes. For most workloads, that is negligible. The operational risk is a node crash losing the unreturned portion of a batch, which creates gaps in the sequence. Gaps are generally harmless but sometimes unexpected.

Sequences break under multi-region active-active writes. You need a global coordinator, which means cross-region latency on every write. If you are running truly multi-region writes, sequences are the wrong tool.

Tradeoffs Comparison

DimensionUUID v4UUID v7SnowflakeULIDDB Sequence
SortabilityNoneTime-ordered (ms)Time-ordered (ms)Time-ordered (ms)Strictly monotonic
Storage128 bits / 36 chars128 bits / 36 chars64 bits / 8 bytes128 bits / 26 chars64 bits / 8 bytes
Coordination requiredNoneNoneMachine ID assignmentNoneYes (sequence server)
Clock dependencyNoneSoft (timestamp embedded)Hard (throws on skew)Soft (timestamp embedded)None
Collision probability1 in 5.3 x 10^36 (per 2 IDs)~same as v4Zero under correct machine ID assignment~same as v4Zero
Timestamp leakageNoneYes (ms precision)Yes (ms precision)Yes (ms precision)Partial (order leaks sequence)
DB index efficiencyPoor (random)Good (near-sequential)Good (near-sequential)Good (near-sequential)Best (sequential)
RFC / standardRFC 9562RFC 9562De facto, no RFCCommunity specN/A

Production Considerations

Clock Skew in Multi-Region Deployments

NTP keeps clocks aligned to within a few milliseconds under normal conditions, but a congested or misconfigured NTP client can drift further. VM live migrations and container restarts can produce step changes. Cloud providers like AWS and GCP offer higher-accuracy time services (EC2 Time Sync Service, Google’s TrueTime), but you should instrument your system regardless.

For Snowflake IDs, the clock backward case is the failure mode to engineer for explicitly. Throwing an error is correct when latency spikes are less acceptable than write errors. Waiting is correct when you have buffered writes and can absorb a few milliseconds of delay. Borrowing from the sequence (incrementing the 12-bit sequence counter across milliseconds) is a third option but creates IDs with technically incorrect timestamps.

For UUID v7 and ULID, a clock running backward means same-millisecond IDs may lose their monotonic ordering guarantee. At scale, this creates short windows where inserts are not clustered at the index tail. The impact is typically negligible, but in write-heavy workloads it is worth monitoring.

Machine ID Assignment at Scale

If you adopt Snowflake IDs, machine ID allocation is the operational problem you will underestimate. Options in order of operational complexity:

StatefulSet pod ordinals: In Kubernetes, use a StatefulSet and extract the ordinal from the hostname. Works reliably when pod count stays within 1,024.

Atomic counter in Redis: INCR machine_id_counter with expiry on a lease key. Pod acquires an ID on startup, holds a lease, releases on graceful shutdown. Crash recovery requires lease expiry.

Assigned via etcd or ZooKeeper: More robust than Redis for lease management but heavier to operate.

The critical invariant: no two live instances share a machine ID at the same time. Anything that can violate this (crash loops, missed lease renewals, rapid cycling) requires explicit mitigation.

Migrating Between ID Schemes

Migrations between ID schemes are painful because IDs are foreign keys everywhere. The practical approach is a dual-write period:

  1. Add a new ID column alongside the old one (nullable).
  2. Generate new IDs for all new writes using the new scheme.
  3. Backfill the new column for existing rows in background batches.
  4. Once backfill is complete, add the non-null constraint.
  5. Update application code to use the new column as the primary join key.
  6. After verification, drop the old ID column.

The verification step is where teams cut corners and pay for it later. Write a query that confirms every foreign key reference has been updated before you drop the old column. Generate a sample of IDs from both schemes and confirm sort order in your application before flipping.

One non-obvious cost: any external system that has stored your old IDs (webhooks, partner integrations, third-party indexes) will break silently when the old IDs disappear. Maintain a lookup table mapping old IDs to new IDs for a deprecation window.

Observability

Monitor the health of your ID generation with these signals:

  • Clock skew events: Counter of times Snowflake generator detected a backward clock, with the magnitude. Alert when magnitude exceeds 100ms.
  • Sequence exhaustion rate: How often the 12-bit Snowflake sequence counter wraps within a millisecond. This is the ceiling on per-node write throughput (4,096 IDs/ms/node). Approaching that ceiling means you need more nodes or a different scheme.
  • ID parse failures: When you decode IDs for debugging (timestamp extraction, machine ID extraction), log failures. Corrupt IDs or format mismatches surface early this way.
  • Index page splits: Database-level metric. Should trend down after migrating from random UUIDs to any time-ordered scheme.

Choosing the Right Scheme

Start here:

  • Are you building a new system with no legacy constraints? Use UUID v7. It requires no coordination, sorts well in indexes, and is an RFC standard.
  • Do you need 64-bit IDs for storage efficiency (integer foreign keys, compact over-the-wire encoding)? Use Snowflake IDs. Budget for machine ID management.
  • Is your write volume low and external-facing IDs should not leak timing? Use UUID v4.
  • Are you using a single-region PostgreSQL database and want the simplest possible solution? Use BIGSERIAL. The coordination cost is a non-issue at single-region scale.
  • Are you generating IDs in a browser or edge environment where you need URL-safe strings without hyphens? ULID is a reasonable choice, but verify that your libraries agree on same-millisecond monotonicity behavior.

The worst outcome is mixing schemes within a single service without a clear reason. UUID v4 for external IDs and UUID v7 for internal IDs is a coherent pattern. Snowflake for events and UUID v4 for users is also coherent. Using all four because different engineers made different choices at different times is not.

Closing

Unique IDs are infrastructure. They are invisible until they are wrong, at which point they are expensive to fix because they are woven through every table in your database and every API contract you have made. The decision deserves more deliberate treatment than grabbing the first UUID library in your package manager. UUID v7 has closed most of the practical gap between random UUIDs and Snowflake IDs, which narrows the field considerably for new systems. For everything else, the tradeoffs are well-understood: make the choice explicitly, document it, and instrument the failure modes from day one.

More in System Design

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL
System Design ·

How Amazon Aurora Works Internally: The Log-Is-the-Database Architecture, Quorum Writes, and Storage-Compute Separation Behind Cloud-Native SQL

A deep dive into Aurora's storage-compute separation, the log-is-the-database design that pushes redo log processing to storage nodes, quorum-based replication across 6 copies in 3 AZs, protection group architecture, fast cloning, Serverless v2 scaling mechanics, and honest tradeoffs versus RDS, self-managed PostgreSQL, CockroachDB, and Cloud Spanner.

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale
System Design ·

How CockroachDB Works Internally: Distributed SQL, Raft Consensus Per Range, and the Architecture Behind Serializable Transactions at Global Scale

A deep dive into CockroachDB's internals: the 512MB range-based data model with automatic splitting, per-range Raft replication, MVCC timestamp ordering with hybrid logical clocks, DistSQL physical planning, serializable transactions with timestamp refreshes, closed timestamps for follower reads, online schema changes using multi-version schema descriptors, and production considerations for hotspots and write amplification.

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation
System Design ·

How Container Runtimes Work Internally: Namespaces, Cgroups, and the OCI Stack From Docker Run to Process Isolation

Containers are not virtual machines and they are not magic. They are a thin composition of Linux kernel primitives: namespaces, cgroups, and a layered filesystem. This article traces exactly what happens between docker run and a running process, covering the OCI spec, containerd, runc, overlay filesystems, and container networking at the kernel level.

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally
System Design ·

How YugabyteDB Works Internally: DocDB Storage, Tablet Splitting, and the Dual API Architecture That Scales PostgreSQL Horizontally

A deep dive into YugabyteDB internals covering the DocDB storage engine built on RocksDB LSM trees, per-tablet Raft consensus, YSQL and YCQL dual query layers, distributed MVCC with hybrid logical clocks, automatic tablet splitting and rebalancing, xCluster multi-region replication, and production considerations for hotspots, connection pooling, and schema design.