Skip to content
Knowledge Hub

Logical clocks: ordering cause and effect when service clocks disagree Intermediate

Estimated reading time: 3 min read

Orders writes the event “order 42 created” when its machine clock shows 10:00:05. Payments receives the event and logs “processed” when its machine clock shows 10:00:03.

Sort the logs by physical timestamp and “processed” comes before “created”. That is not a Kafka bug: the two machines’ clocks are skewed, or NTP just adjusted the time.

Two different questions:

Question Tool
How much time has passed? Wall clock (and a monotonic clock for deadlines)
Which event could have caused which? Logical clock

Happens-before (a → b):

  1. In the same process, a runs before b: a → b.
  2. a sends a message and b receives that same message: a → b.
  3. Transitivity: a → b and b → c imply a → c.

Two events with no causal path between them are concurrent: neither can be ordered first. It does not mean the CPU ran them in the same nanosecond.

Lamport clock: before each local or send event, L = L + 1; a send carries L in the message; on receiving a message carrying m, L = max(L, m) + 1. Guarantee: a → b implies L(a) < L(b). The converse does not hold.

class Lamport {
private l = 0;
tick() { return ++this.l; } // local or send
receive(m: number) { this.l = Math.max(this.l, m) + 1; return this.l; }
}

Drop Math.max and the receiving process can end up with an L lower than at send time, violating a → b ⇒ L(a) < L(b).

Messages carry causal information, not physical clocks
Messages carry causal information, not physical clocksStep 1/4: Orders increments its logical clock to 1 and sends a message carrying timestamp 1.OrdersMessagePaymentsL=1
Orders → MessageL=1
Step 1/4

Orders increments its logical clock to 1 and sends a message carrying timestamp 1.

Actual demo output (running examples/src/lamport-clock/clock-demo.ts):

Orders sends event: 1 wall-clock: 10:00:05
Payments local event: 1 wall-clock: 10:00:02
Payments receives event: 2 wall-clock: 10:00:03
Orders next local event: 2 wall-clock: 10:00:06

Payments’ wall clock (10:00:03) is lower than Orders’ (10:00:05), but Lamport is still correct: L(send) = 1 < L(receive) = 2. The time strings are assigned on purpose to simulate skew; the script does not change the operating system clock.

Three processes: Lamport clock and vector clock for each event
Three processes: Lamport clock and vector clock for each eventStep 1/4: P1 sends m1 (a) and P3 has an internal event (d). No message path connects these two events, so a and d are concurrent even though both have L = 1.local time (not synchronized)a: send m1L1 [1,0,0]d: localL1 [0,0,1]P1 OrdersL and vector [P1,P2,P3]P2 PaymentsP3 Inventory
Step 1/4

P1 sends m1 (a) and P3 has an internal event (d). No message path connects these two events, so a and d are concurrent even though both have L = 1.

Pair L Vector clock Conclusion
a, b 1 < 2 [1,0,0] less than [1,1,0] a → b (message m1 exists)
d, c 1 < 3 [0,0,1] and [1,2,0] are not comparable Concurrent, even though L(d) < L(c)
f, e 2 < 4 [2,0,0] and [1,2,2] are not comparable Concurrent, even though L(f) < L(e)

The Lamport clock gives L(d) < L(c), yet d did not cause c.

A vector clock keeps one component per process, so it captures both directions: V(a) < V(b) if and only if a → b. The price is metadata that grows with the number of processes.

If you only need a total order for tie-breaking, sort by (L, processId); that order does not turn two concurrent events into causally related ones.

Some stores resolve conflicting writes with last-write-wins (LWW) based on timestamps, for example Cassandra (per cell, timestamp from the client or coordinator) and DynamoDB global tables across regions. (Amazon’s original Dynamo paper used vector clocks and let the application reconcile.) Suppose A’s clock is 5 seconds fast:

Real time Writer Value Written timestamp
10:00:00 A (clock 5 s fast) x = 1 10:00:05
10:00:02 B (correct clock) x = 2 10:00:02

LWW keeps x = 1 because its timestamp is larger, even though B wrote later, and no error is reported. This is the real reason to care about clocks: silent data loss. For important data, use versions/vector clocks with a merge rule, or a clock with bounded error as below.

Technique Idea Used in
Hybrid Logical Clock (HLC) Timestamp = wall clock plus a logical counter; tracks real time while preserving causality CockroachDB, MongoDB (cluster time)
TrueTime An API that returns an interval [earliest, latest] with bounded error; Spanner waits out the uncertainty (commit wait) before committing Google Spanner

A note on using clocks: use a monotonic clock for deadlines and measuring durations (the wall clock can jump backward on an NTP step or leap second); use the wall clock only for display and logs.

Adding logical clocks to a real system requires decisions: store them in a header or an envelope, restore them after restart (the demo keeps them in memory, so they are lost when the process dies), and make sure proxies do not drop the header.

What logical clocks help with

  • Preserve the send → receive relationship without perfectly synchronized clocks
  • Small metadata, simple rules
  • Separate causal order from wall-clock display order

What they do not solve

  • Do not measure real time; do not use them as a TTL or deadline
  • L(a) < L(b) does not prove a → b (vector clocks are needed)
  • Do not by themselves solve concurrent writes, idempotency or cross-service transactions

Self-check: (1) Why does Payments log 10:00:03 even though the message it received was logged at 10:00:05? (2) Are two events with L = 2 concurrent? (3) In the three processes above, what does the vector clock tell you that L does not?