Learn how distributed systems generate unique IDs, why wall clocks cannot be trusted, and how Lamport, vector and hybrid logical clocks put events in a sensible order.
What you'll learn: By the end of this lesson you will be able to choose an ID scheme (UUID, Snowflake-style, ULID) for a given workload and explain its trade-offs, explain why timestamps from different machines cannot be compared safely, and use Lamport clocks, vector clocks and hybrid logical clocks to reason about "what happened before what".
Imagine a busy post office with ten counters. Each counter stamps parcels with a tracking number and a time. If every clerk invents numbers on their own, two parcels may get the same number. If every clerk reads a different wall clock, a parcel stamped "10:05" at one counter may really have arrived after a parcel stamped "10:07" at another. Customers will ask the obvious questions: which parcel came first, and is this number really unique?
Distributed systems face the same two problems at a much larger scale. Thousands of machines create orders, messages and rows at the same moment, and none of them shares a clock or a counter. In a single database, a sequence and a timestamp solve both problems for free. Across machines, you have to design for them.
This lesson covers both halves: generating identifiers that never collide, and agreeing on an order of events without trusting physical clocks. It builds on consensus and leader election and prepares the ground for the URL shortener case study.
The core idea
Two facts drive everything here.
There is no global "now". Each machine has its own clock. Clocks drift, get corrected, and sometimes jump backwards. Messages take unpredictable time to travel. So "which event happened first?" often has no physical answer you can read off a clock.
Coordination is expensive, so avoid it when you can. A central counter gives perfect unique IDs but is a bottleneck and a single point of failure. The art is to get uniqueness and "good enough" ordering with little or no coordination.
So separate three goals that people often blur together:
Goal
Question
Typical tool
Uniqueness
Can two things ever share an ID?
Random bits, or worker ID plus sequence
Rough time order
Do newer IDs sort after older IDs, roughly?
Timestamp prefix in the ID
Causal order
If A could have influenced B, is A ordered before B?
Logical clocks, per-key sequence numbers, or a log
An ID scheme can give you the first two. Only logical clocks, per-entity version numbers or a consensus log give you the third in a way you can rely on.
Generating unique IDs
For a primary key in a large system you usually want four things: unique without asking a central service, compact (smaller keys mean smaller indexes), roughly sortable by time (inserts land at the end of a B-tree, not at random places), and non-guessable when exposed in URLs. No scheme wins on all four.
The main options
Auto-increment integers. The database hands out 1, 2, 3. They are compact and perfectly ordered, but they need one writer. With a sharded or multi-leader database you need a separate sequence per node, and sequential IDs leak information: a competitor can estimate your order volume from order numbers.
UUID v4. A 128-bit identifier in which 122 bits are random. Collisions are astronomically unlikely and any machine can generate one with no coordination. The cost: 16 bytes (36 characters as text), and being random they scatter inserts across a B-tree index, causing page splits and poor cache locality on write-heavy tables.
UUID v7. Places a 48-bit Unix millisecond timestamp first, followed by random bits. It keeps the 128-bit format and the no-coordination property, and new rows sort near the end of the index.
ULID. The same idea: 48 bits of millisecond timestamp and 80 bits of randomness, written as 26 characters in a sortable text encoding. Handy when IDs appear in URLs or logs.
Snowflake-style IDs. Popularised by Twitter and since copied in many variants. A 64-bit integer is split into fields:
| 1 bit unused | 41 bits: milliseconds since a custom epoch | 10 bits: worker ID | 12 bits: sequence |
With 41 bits of milliseconds you get roughly 69 years from your chosen epoch. 12 bits of sequence allow 4,096 IDs per millisecond per worker, and 10 bits allow 1,024 workers. The exact split varies between implementations; the principle is what matters: time makes IDs roughly sortable, the worker ID prevents collisions between machines, and the sequence handles many IDs in the same millisecond.
Scheme
Size
Sortable by time
Needs coordination
Main cost
Good for
Auto-increment
4 to 8 bytes
Exactly
One writer
Bottleneck, leaks volume
Single database
UUID v4
16 bytes
No
None
Index scatter, size
Public, unguessable IDs
UUID v7 / ULID
16 bytes
Roughly (ms)
None
Reveals creation time
General primary keys
Snowflake-style
8 bytes
Roughly (ms)
Worker ID assignment
Clock dependence
High write volume, compact keys
The flowchart below is the order in which a design review usually asks the questions.
flowchart TD
S["Need an ID scheme"] --> Q1{"One database writes all rows?"}
Q1 -->|yes| A1["Auto-increment, kept internal"]
Q1 -->|no| Q2{"Keys must fit in 64 bits?"}
Q2 -->|yes| Q3{"Can you own worker IDs and clock handling?"}
Q3 -->|yes| A2["Snowflake-style"]
Q3 -->|no| A3["Per-shard sequence plus shard prefix"]
Q2 -->|no| Q4{"Write-heavy table with B-tree indexes?"}
Q4 -->|yes| A4["UUID v7 or ULID"]
Q4 -->|no| A5["UUID v4"]
Read it top to bottom: a single writer makes the problem disappear; otherwise the 64-bit requirement is what pushes you toward Snowflake-style IDs and their operational duties. Whatever you pick, if the ID is shown to customers, add a separate unguessable public identifier rather than exposing the internal key.
The Snowflake duties: clocks and worker IDs
A Snowflake-style generator depends on the machine clock, so it must handle a clock that moves backwards, or it may issue an ID it already issued.
flowchart TD
N["Request an ID"] --> T["Read clock"]
T --> C{"Clock behind last issued time?"}
C -->|no| S{"Same millisecond as last ID?"}
S -->|yes| Q["Increment sequence"]
Q --> O{"Sequence overflowed?"}
O -->|yes| W["Wait for next millisecond"]
W --> T
O -->|no| I["Issue ID"]
S -->|no| R["Reset sequence to zero"]
R --> I
C -->|yes| D{"Gap under a few ms?"}
D -->|yes| W2["Wait out the gap"]
W2 --> T
D -->|no| F["Refuse and report unhealthy"]
A small regression is waited out. A large one means the generator stops issuing and tells the platform it is unhealthy, because guessing is how duplicates are born.
Worker IDs need an owner too. Common approaches: assign them from a coordination service such as ZooKeeper or etcd at startup, derive them from a stable instance number (for example a Kubernetes StatefulSet ordinal), or lease them with an expiry so a crashed worker's ID is not reused while it might still be alive.
NOTE
"Roughly sortable" means sortable to the millisecond, plus whatever skew the generating machines have. Two IDs created on different machines in the same few milliseconds can sort either way. Do not use ID order as proof of event order.
Why you cannot trust the wall clock
A physical clock is a quartz crystal counting oscillations. It runs slightly fast or slow, by an amount that changes with temperature. That error is drift: two machines that agree at noon disagree a little by evening.
To limit drift, machines use NTP (Network Time Protocol) to ask a time server for the correct time. NTP is good but not perfect: over a network the error is often milliseconds to tens of milliseconds, and during network trouble it can be far worse. Time can be stepped (jumped) or slewed (sped up or slowed down gradually). Leap seconds add surprises, and virtual machines that pause and resume can wake with a badly wrong clock.
The difference between two machines' clocks at one instant is clock skew, and it creates concrete bugs:
sequenceDiagram
participant A as Node A fast clock
participant B as Node B slow clock
participant S as Store
A->>S: write x=1 at t=10:00:05
B->>S: write x=2 at t=10:00:03
Note over S: Real order was A then B, yet the timestamps say B is older
Note over S: Last write wins keeps x=1 and silently drops x=2
B really wrote after A, but because B's clock runs behind, "last write wins by timestamp" keeps A's value and discards the newer write. Nobody sees an error; data is simply lost. This is why timestamp-based conflict resolution needs care, as revisited in multi-region and disaster recovery.
The practical rules:
Use monotonic clocks for durations. They never jump backwards. Use the wall clock only for "what time is it for a human".
Never compare timestamps from two machines and conclude causality unless you bound the error.
Make leases skew-tolerant. A lock that "expires at 10:00:05" only means something if everyone agrees on 10:00:05, so lease protocols measure durations locally, with a safety margin.
Happens-before and logical clocks
If wall clocks cannot order events, what can? In 1978 Leslie Lamport observed that for most purposes we only care about causality: could event A have influenced event B? He defined the happens-before relation, written A → B:
If A and B are on the same process and A occurs first, A → B.
If A is the sending of a message and B is its receipt, A → B.
It is transitive: if A → B and B → C, then A → C.
If neither A → B nor B → A, the events are concurrent. That does not mean simultaneous; it means no information could have flowed from one to the other. Concurrent events have no meaningful order, and a correct system must not pretend otherwise.
Lamport clocks
A Lamport clock is a counter on each process: increment before each local event; when sending, increment and attach the counter; on receipt, set your counter to max(local, received) + 1.
sequenceDiagram
participant P1 as Process 1
participant P2 as Process 2
Note over P1: counter 1
P1->>P2: message with counter 1
Note over P2: counter becomes max of 0 and 1, plus 1, so 2
Note over P2: reply is a send event, counter 3
P2->>P1: reply with counter 3
Note over P1: counter becomes max of 1 and 3, plus 1, so 4
The guarantee: if A → B then the Lamport timestamp of A is less than that of B. The reverse is false: if timestamp(A) < timestamp(B), A might have happened before B, or they might be concurrent. Breaking ties with the process ID gives a total order consistent with causality, which many algorithms (mutual exclusion, replicated logs) use.
Vector clocks
To detect concurrency, each process keeps a vector with one counter per process. On a local event, increment your own entry. On sending, attach the vector. On receiving, take the element-wise maximum, then increment your own entry. Comparing vectors tells the truth: if every entry of V1 is at most the matching entry of V2 (and one is smaller), V1 happened before V2; if neither dominates, the events are concurrent.
Vector A
Vector B
Meaning
[2, 1, 0]
[3, 1, 1]
A happened before B
[2, 1, 0]
[1, 2, 0]
Concurrent: each is ahead somewhere
Dynamo-style stores used this idea to detect that two replicas accepted conflicting writes, then handed both versions to the application to merge. The cost is size: a vector grows with the number of writers, so real systems prune or use variants such as dotted version vectors.
Hybrid logical clocks and TrueTime
Lamport and vector clocks bear no relation to real time, which is awkward when people ask for "everything from last Tuesday". A hybrid logical clock (HLC) combines a physical timestamp with a small logical counter: use the maximum of the local physical time and the highest timestamp seen so far, and if that equals the previous value, bump the counter. The result stays close to wall-clock time, never goes backwards, and respects causality: a receiver's HLC is always greater than the sender's. Several distributed databases use HLC-style timestamps.
Google's Spanner takes another route. Its TrueTime API returns an interval designed to contain the true time, backed by GPS receivers and atomic clocks. Before committing, a transaction waits long enough that different transactions' intervals cannot overlap confusingly. The lesson travels well: if you can bound clock uncertainty, you can wait it out; if you cannot, do not rely on physical time for correctness.
Choosing an ordering guarantee
Ordering is a spectrum, and stronger ordering costs more coordination.
Guarantee
What it means
Cost
Typical use
No order
Events may arrive in any order
Lowest
Independent, commutative updates
Per-key / per-partition order
Events with the same key keep their order
Low
Kafka partitions, per-user streams
Causal order
If A → B, everyone sees A before B
Medium
Comments and replies, collaborative editing
Total order
Everyone sees one agreed sequence
High: a leader or consensus
Financial ledgers, replicated state machines
flowchart TD
Q["Do consumers need an order?"] -->|no| N1["Make updates commutative, no order"]
Q -->|yes| K{"Only within one entity?"}
K -->|yes| P["Partition by entity key plus version number"]
K -->|no| C{"Across entities, only for cause and effect?"}
C -->|yes| H["Causal metadata such as HLC or vector clocks"]
C -->|no| T["Single log or consensus group"]
Most business needs stop at the second box. A chat app needs messages within one conversation ordered, not a global order; partition by conversation and you avoid global coordination. See event streaming for how partitions provide exactly this.
In practice: order IDs and event ordering for a multi-service retail platform
NOTE
A realistic scenario, not a case history. This is a composite example built from common production patterns. It does not describe any specific company, and every number in it is an illustrative assumption.
The situation
A retail platform has an order service, payment service, inventory service and shipping service, owned by four teams of about six engineers each. Orders live in a single PostgreSQL primary with an auto-increment order_id, and services publish events to Kafka. Two problems have reached the architecture review. First, the database is nearing its write ceiling, so the plan is to shard orders across eight PostgreSQL shards, which breaks the single sequence. Second, customer support complains that the order timeline sometimes shows "shipped" before "paid". Each service stamps events with its own created_at, and with a skew of a few hundred milliseconds between hosts (an assumption), a payment confirmation and the shipping label it triggers can be stamped in the wrong order. Constraints: no downtime during the change, a public order number that must not reveal daily volume, and a six-week window before peak season.
Starting architecture
Options considered
Option
What it fixes
What it costs
Risk
Do nothing beyond sharding with a sequence per shard and a shard prefix
Uniqueness across shards, minimal change
IDs from different shards are not time-sortable; volume still leaks
Low, but the timeline bug stays
UUID v7 as primary key, plus Kafka key and per-order version
No coordination, sortable, fixes the timeline
16-byte keys in every index and foreign key
Medium: wider indexes, every consumer touched
Snowflake-style 64-bit ID from the order service, per-order version, separate public order number
Compact keys, time-sortable, no central allocator
Worker ID assignment and clock-regression handling to own
Medium: clock failure modes become the team's problem
The decision
Field
Entry
Context
Eight shards need coordination-free IDs; the support timeline must stop depending on cross-host timestamps.
Decision
Snowflake-style 64-bit internal IDs generated in the order service, with worker IDs taken from the StatefulSet ordinal. A random public order number (Crockford base32 with a check digit) is stored in a unique index. Every order event carries order_id as the Kafka key and a monotonically increasing order_version assigned by the order service. The timeline sorts by (order_id, order_version), placing each payment or shipping event after the order version in its caused_by_version, and never uses wall-clock time for ordering.
Consequences
Good: compact indexes, no leaked volume, per-order order guaranteed by partition plus version. Bad: events from payment and shipping need the order version they react to (caused_by_version), a contract change for two teams; the generator needs clock-regression handling.
Revisit when
More than 1,024 generator instances are needed, the order service is split by domain, or support needs a global cross-order ordering.
Rollout and verification
flowchart LR
P1["Phase 1: add new columns, backfill"] --> P2["Phase 2: dual-write IDs behind a flag"]
P2 --> P3["Phase 3: consumers read order_version"]
P3 --> P4["Phase 4: switch timeline sort away from created_at"]
P4 --> P5["Phase 5: shard cutover"]
Ship the new columns first and backfill order_version from the existing event log. Dual-write the new ID and the old sequence value behind a feature flag, canary on one pod, then ramp. Run the timeline in shadow mode, computing both orderings and logging every order where they disagree; the disagreement count should fall to zero for new orders. Dashboards to watch: ID generation rate per worker, id_clock_regression_total, duplicate-key errors on the order table (target zero), consumer lag per partition, and a daily check that order_version has no gaps per order. Load test generators at a multiple of peak order rate before touching sharding.
The 2 a.m. failure
The page reads "Order creation error rate above 2 percent on order-service-3". The cause is a node whose NTP daemon stepped the clock back 1.8 seconds after a VM pause. The generator detects the regression, exceeds its small wait threshold, and marks itself unhealthy, so one pod stops issuing IDs while the rest continue.
sequenceDiagram
participant NTP as NTP daemon
participant G as ID generator pod 3
participant K as Kubernetes
participant OC as On-call
NTP->>G: step clock back 1.8 s
G->>G: now is before last issued time
G->>K: readiness fails, drain pod
G->>OC: alert clock regression
OC->>G: check clock offset and logs
OC->>K: cordon node, reschedule pod
First three checks: kubectl logs order-service-3 | grep clock_regression to confirm the cause; chronyc tracking on the node for the offset and last step; and a query for duplicate-key violations on the orders table to prove no duplicate was issued (expected zero). Mitigation: cordon the node and let the pod reschedule onto a healthy host; because the worker ID is the StatefulSet ordinal, the new pod must not issue IDs until its clock passes the last timestamp persisted by the old one, so the generator stores a high-water mark periodically and refuses to start below it. Follow-up fix: configure chrony to slew rather than step after startup, alert on offset above a threshold before it becomes a step, and add the high-water mark to the test suite.
Architect's notes
Measure skew before arguing about clocks. Export each host's offset from its time source and graph the worst case; the number decides whether you need logical ordering at all.
Ask in the review: "who assigns the order?" For every event stream, name the single component that owns the sequence. If the answer is "the consumer's clock", the design is wrong.
A per-entity version number is the cheapest ordering you can buy. It costs one column and an increment inside the transaction that already exists, and it removes most arguments about timestamps.
The cost trap is wide keys. A 16-byte key repeats in every secondary index and foreign key, so storage and cache footprint grow faster than the table itself.
Public IDs are a product decision, not just an encoding. Decide early whether customers may guess, enumerate or type them; changing the format later touches emails, support scripts and partner integrations.
Defer HLC and vector clocks until you have a real multi-writer conflict problem. Most services need only partition order plus a version.
Common mistakes
Using now() timestamps to order events from different machines. Skew can reverse real order. Use a logical clock, a per-entity sequence from one owner, or a bounded-uncertainty clock.
Random UUID v4 as the key of a huge write-heavy table. Random inserts fragment the index. Prefer a time-ordered ID.
Ignoring backwards clock jumps in an ID generator. It can issue duplicates. Detect the jump and block or fail.
Exposing sequential IDs publicly. They let outsiders count your records. Use unguessable public IDs and keep internal IDs private.
Treating "same millisecond" as concurrency. Concurrency means no causal path, not equal timestamps; different timestamps do not prove causality either.
Measuring timeouts with the wall clock. A clock step can fire or suppress timeouts. Use the monotonic clock.
Assuming Lamport order equals real order. It only guarantees that causes get smaller numbers than their effects.
Key takeaways
There is no global clock. Clocks drift and skew, and NTP narrows but never removes the error.
Uniqueness, time-sortability and causality are different goals. An ID scheme covers the first two; logical clocks or a log cover the third.
UUID v4 is simple and unguessable but index-unfriendly. Time-ordered IDs keep inserts local.
Snowflake-style IDs trade coordination for clock dependence. Handle worker ID assignment and backwards jumps explicitly.
Happens-before is the real definition of order. Events with no causal path are concurrent.
Lamport clocks give a causality-consistent total order; vector clocks detect concurrency.
Hybrid logical clocks keep timestamps close to real time while respecting causality.
Buy only the ordering you need. Per-partition order is cheap; total order needs consensus.
Practice exercises
Exercise 1 — Starter (30 min): A Snowflake-style ID has 41 bits of milliseconds, 10 bits of worker ID and 12 bits of sequence. Calculate (a) how many IDs one worker can issue per second at most, (b) how many years the timestamp field lasts, and (c) the maximum number of workers. Show your arithmetic and state which numbers are exact and which are rounded.
Exercise 2 — Intermediate (2 hours): Three processes P1, P2, P3 exchange messages: P1 sends m1 to P2, then P2 sends m2 to P3, then P3 does a local event, and separately P1 does a local event after sending m1. Draw the timeline and compute Lamport timestamps and vector clocks for every event. Identify one pair of events that is concurrent and explain why the Lamport timestamps alone would not reveal this.
Exercise 3 — Advanced (half day): Design the ID and ordering strategy for a multi-tenant order system with 20 database shards and several application regions. Choose an ID format, explain how worker IDs are assigned and how a backwards clock jump is handled, decide which entities need causal order versus per-key order, and describe what the system does when two regions update the same order concurrently. Write a one-page design note and list two failure scenarios with their mitigations.
Lesson 5 of 14 · Advanced System Design and Case Studies
Learn how distributed systems generate unique IDs, why wall clocks cannot be trusted, and how Lamport, vector and hybrid logical clocks put events in a sensible order.
In the rest of this lesson
Why you cannot trust the wall clock
Happens-before and logical clocks
Choosing an ordering guarantee
In practice: order IDs and event ordering for a multi-service retail platform
Architect's notes
Common mistakes
Enrolling is free. It unlocks every lesson in Advanced System Design and Case Studies, tracks your progress across devices and keeps your quiz scores. The newsletter is optional.
Real architecture decisions, LLMOps patterns that survive production, and engineering leadership advice — from 12+ years of building at enterprise scale. Free. No spam. Unsubscribe anytime.
Get new articles by email
Free. We email a confirmation link first, and you can unsubscribe from any email.