Why wall clocks cannot order events across services
Clock skew in microservices is the difference between what two hosts believe the current time is. It is a correctness problem, not a precision nuisance: any logic that compares timestamps from different machines assumes the clocks agree, and they never fully do.
Several mechanisms combine to break that assumption. NTP narrows offset but leaves a residual error that depends on network path asymmetry. Between syncs, oscillators drift at rates that vary with temperature and hardware. When a daemon corrects a large offset by stepping rather than slewing, the clock can jump backward. Leap seconds are either repeated as a duplicate second or smeared over many hours, and hosts that handle them differently disagree during that period. VM live migration, hypervisor scheduling and stop-the-world GC pauses let a process read the time, freeze, and then act on a stale value.
How this breaks systems
- Last-write-wins keeps the update with the higher timestamp, which may be the causally older one.
- Staleness checks reject valid events because the producer's clock runs behind the consumer's.
- Time-windowed deduplication misses a retry whose timestamp falls outside the window.
- Incident timelines built from merged logs show effects before their causes.
Illustrative estimate (assumed inputs): node A's clock runs 50 ms fast, and node B's is accurate. A writes v1 at true time 0 ms and stamps it 50. B reads v1 and writes v2 at true time 10 ms, stamping it 10. Under LWW, v1 wins and v2, the newer update, is silently dropped. Any causally ordered pair closer together than the current skew can be reordered this way.
Happens-before: what a clock can and cannot tell you
The fix is not a better clock. It is a weaker, more honest question: did one event cause another, or could it have? Leslie Lamport's happens-before relation, written a → b, answers exactly that. It is built from three rules:
- Program order. If a and b occur in the same process and a comes first, then a → b. A single thread observes its own sequence directly, with no clock involved.
- Messages. If a is the sending of a message and b is its receipt, then a → b. A reply cannot exist before the request it answers.
- Transitivity. If a → b and b → c, then a → c. Causality chains across any number of hops.
If neither a → b nor b → a holds, the events are concurrent. Concurrency here does not mean "simultaneous". It means that no information path connected them, so neither could have influenced the other. A clock that places one concurrent event before another is not wrong, but the order it produces is arbitrary. It can break ties deterministically. It cannot tell you which write the system "meant". Treating that tie-break as causal truth is how updates get lost.
The relation only covers paths the system can observe. Suppose a user reads an order status in one service, then clicks "cancel" in another. That is real causality, but it passes through a browser and a human. No message carried a timestamp from the read to the cancel. To every mechanism that follows, the two events look concurrent unless the client carries the timestamp from the first request to the second.
time ------------------------------------------------------------------->
Order Svc a1 ------> a2 ---------------------> a3 (user reads status)
| : out-of-band path:
| send m : browser + human
v : (no timestamp
Payment Svc b1 ------> b2 ------> b3 : carried)
| :
| send n :
v v
Cancel Svc c1 -----------------> c2 ----------> c3 (user clicks "cancel")
a1 -> a2 program order: same process, a1 first
a2 -> b2 message: the send precedes its receive
a1 -> c2 transitivity: a1 -> a2 -> b2 -> b3 -> c2
a1 || b1 concurrent: no path joins them, so any order is a tie-break
a3 || b3 concurrent: both follow a2, but no path runs between them
a3 || c3 look concurrent: the real cause went through the browser and
the user, so it is visible only if the client forwards a3's
timestamp into the cancel request
Program order and messages define happens-before across three services, while the user's read-then-cancel path stays invisible unless the client carries the timestamp.
How Lamport timestamps and vector clocks work
A Lamport clock is a single integer per process, governed by three rules:
- Before each local event, increment the counter.
- When sending a message, increment and attach the counter.
- On receive, set the counter to
max(local, received) + 1.
The max step makes the receiver's clock jump past anything the sender had seen, which yields the guarantee: if A happened-before B, then L(A) < L(B). The converse does not hold. L(A) < L(B) does not mean A caused B, so a Lamport timestamp cannot detect concurrency.
For a total order, compare (counter, node ID) pairs. Every replica that sorts this way produces the same sequence, which is what replicated logs and lock queues need. For concurrent events, though, the node ID is an arbitrary tiebreaker. "Node 3 wins" is not a business rule, and with a Lamport clock you cannot tell when it is the deciding factor.
Vector clocks remove that blind spot. Each process keeps one counter per participant and increments its own entry on local events. On receive, it takes the element-wise max and then increments its own entry. A ≤ B if every entry of A is ≤ the matching entry of B. If neither vector dominates the other, the events are concurrent. That gives you an explicit conflict to resolve, or to hand to the application, instead of one settled silently by node ID.
The cost is size, which grows linearly with the number of participants. That is workable for a fixed replica set but not for autoscaled pods or per-client entries. Pruning old entries bounds the size, but it can cause concurrent events to be misclassified as ordered, and that brings back the silent overwrite vector clocks were meant to prevent.
class Lamport:
def __init__(self, node_id):
self.counter, self.node_id = 0, node_id
def tick(self):
# Rules 1 and 2: increment before a local event or a send.
self.counter += 1
return (self.counter, self.node_id) # tuples sort as a total order
def receive(self, received_counter):
# Rule 3: jump past everything the sender had seen.
self.counter = max(self.counter, received_counter) + 1
return (self.counter, self.node_id)
class VectorClock:
def __init__(self, node_id):
self.node_id, self.clock = node_id, {}
def tick(self):
# Local event or send: bump only this node's own entry.
self.clock[self.node_id] = self.clock.get(self.node_id, 0) + 1
return dict(self.clock) # send a copy, not a shared reference
def receive(self, received):
# Element-wise max, then count the receive as a local event.
for node, count in received.items():
self.clock[node] = max(self.clock.get(node, 0), count)
return self.tick()
def compare(a, b):
# Entries missing from a vector are treated as 0.
nodes = a.keys() | b.keys()
a_le_b = all(a.get(n, 0) <= b.get(n, 0) for n in nodes)
b_le_a = all(b.get(n, 0) <= a.get(n, 0) for n in nodes)
if a_le_b and b_le_a:
return "equal"
if a_le_b:
return "before"
return "after" if b_le_a else "concurrent" # concurrent: surface the conflict
Minimal Python Lamport and vector clocks: Lamport ticks and receives produce (counter, node ID) pairs for a total order, while the vector clock comparison returns "concurrent" when neither vector dominates, so the conflict is reported instead of being settled by node ID.
How hybrid logical clocks work
Hybrid logical clocks (HLC) avoid that size problem by staying at a fixed size. Each timestamp is a pair (l, c). l is the largest wall-clock reading the node has seen, whether from its own clock or from a peer. c is a logical counter that breaks ties when l does not advance.
The update rules are short:
- Local or send event: set
ltomax(l, now). Ifldid not change, incrementc. Otherwise resetcto 0. - Receive
(lm, cm): setltomax(l, lm, now). Then setcaccording to which input supplied that value:- If both the local
landlmsupplied it,c = max(c, cm) + 1. - If only the local
lsupplied it, incrementc. - If only
lmsupplied it,c = cm + 1. - If
nowwon, resetcto 0.
- If both the local
In this worked example, host A's clock runs fast and reads 1050 when it sends, so the message carries (1050, 0). Host B's clock reads 1001 when the message arrives. B adopts (1050, 1). Its next local event at wall time 1005 becomes (1050, 2). The event caused by A's message is therefore ordered after it, even though B's clock is behind.
Applied to the 50 ms / 10 ms scenario, the later write now sorts correctly when it causally follows the earlier one. If the two writes are concurrent, HLC still orders them by skewed physical time, so the wrong one can still win.
HLC has three benefits. It uses a fixed 64-bit-style footprint. It stays close to real time, and the result is totally comparable, so it works as a database key and supports time-range queries.
These benefits depend on bounded skew. If one host's clock runs minutes ahead, every peer it talks to adopts that l, and the error spreads through the system. Implementations should reject or alarm on remote timestamps that are too far beyond local time.
class HLC:
"""Hybrid logical clock: (l, c) = (max wall time seen, tie-breaking counter)."""
def __init__(self, max_drift_ms=100):
self.l, self.c = 0, 0
self.max_drift = max_drift_ms # reject peers too far ahead of our clock
def send(self, now):
# Local or send event: advance l to wall time if it moved, else bump c.
l_old = self.l
self.l = max(l_old, now)
self.c = self.c + 1 if self.l == l_old else 0
return (self.l, self.c)
def receive(self, msg, now):
lm, cm = msg
# Guard against a fast peer dragging our l (and everyone else's) forward.
if lm - now > self.max_drift:
raise ValueError(f"remote l={lm} is {lm - now} ms ahead of local clock")
l_old = self.l
self.l = max(l_old, lm, now)
if self.l == l_old and self.l == lm:
self.c = max(self.c, cm) + 1 # both supplied l
elif self.l == l_old:
self.c += 1 # only local l supplied it
elif self.l == lm:
self.c = cm + 1 # only the message supplied it
else:
self.c = 0 # local wall clock won
return (self.l, self.c)
# Worked example: host A runs fast, host B is behind (times in ms).
a, b = HLC(), HLC()
b.send(1000) # B's earlier event -> (1000, 0)
msg = a.send(1050) # A sends -> (1050, 0)
recv = b.receive(msg, 1001) # B receives -> (1050, 1)
next_evt = b.send(1005) # B's next event -> (1050, 2)
# Tuples compare lexicographically, so causal order holds despite B's slow clock.
assert msg < recv < next_evt
print(msg, recv, next_evt)
A minimal Python HLC implementing the send and receive rules, a max-drift check on incoming timestamps, and the two-host worked example where B's events sort after A's message even though B's clock is behind.
Implementing clock propagation across service boundaries
The clock only orders events if every hop carries it forward. Treat the timestamp as part of the message contract. Put it in a transport header for synchronous calls and in the event envelope for anything that goes through a broker or queue. A minimal envelope holds event_id, type, payload, wall_time, and the logical clock fields: l and c for a hybrid logical clock, or a single counter for Lamport timestamps.
Handle the clock in middleware, not in business code. On receive, the middleware merges the remote timestamp into the local clock before the handler runs. On send, it ticks the clock and stamps the outgoing message. Asynchronous consumers follow the same rule. A retry should keep the original event's timestamp, because the retry is the same event. Any new events the handler emits get fresh ticks. When you write state, persist the timestamp alongside the row or document. If you don't, you cannot later order a read of that data relative to anything else.
A single hop that drops the header breaks the causal chain, and nothing reports an error. Common causes are a proxy that strips unknown headers, a batch job, or a hand-written client. Downstream services see a "fresh" event and order it incorrectly. Add contract tests and a metric that counts messages arriving without a timestamp.
Operational guardrails
- Monitor clock offset per host against your time source, and alert on drift.
- Enforce a maximum tolerated skew. Reject remote timestamps beyond it, or quarantine them and alert.
- Record both wall time and logical time in logs and trace spans. Wall time helps humans read the logs, and logical time gives the correct order.
import threading, time, uuid
MAX_SKEW_MS = 500 # placeholder; set from your measured NTP/PTP accuracy
class HLC:
def __init__(self):
self.l, self.c, self._lock = 0, 0, threading.Lock()
def tick(self): # local or send event
with self._lock:
pt = time.time_ns() // 1_000_000
if pt > self.l: self.l, self.c = pt, 0
else: self.c += 1
return self.l, self.c
def merge(self, lm, cm): # receive event: fold remote clock into local
with self._lock:
pt = time.time_ns() // 1_000_000
if lm - pt > MAX_SKEW_MS: # guardrail: refuse far-future timestamps
raise ValueError(f"remote clock {lm - pt}ms ahead; quarantine")
l = max(self.l, lm, pt)
if l == self.l == lm: self.c = max(self.c, cm) + 1
elif l == self.l: self.c += 1
elif l == lm: self.c = cm + 1
else: self.c = 0
self.l = l
return self.l, self.c
clock, missing_ts_total = HLC(), 0 # export the counter as a metric
def stamp(event_type, payload): # every outgoing event gets a fresh tick
l, c = clock.tick()
return {"event_id": str(uuid.uuid4()), "type": event_type, "payload": payload,
"wall_time_ms": time.time_ns() // 1_000_000, "l": l, "c": c}
def consume(envelope, handler): # same middleware for sync and async consumers
global missing_ts_total
if "l" in envelope and "c" in envelope: clock.merge(envelope["l"], envelope["c"])
else: missing_ts_total += 1 # a dropped header fails silently; make it visible
# Retries redeliver this same envelope, so l/c stay unchanged; the handler
# persists (l, c) with any state it writes and calls stamp() for new events.
return handler(envelope)
A minimal hybrid logical clock with consume-side middleware that merges the remote timestamp, rejects far-future skew, counts envelopes missing a timestamp, and stamps new outgoing events with fresh ticks.
Choosing a mechanism, when to avoid logical clocks, and alternatives
Choose the cheapest mechanism that answers the question your system actually asks.
- Lamport clocks fit when you need one total order that respects causality, such as replaying an audit log or breaking ties deterministically. They cost one integer per message. They cannot tell you whether two events were concurrent.
- Vector clocks fit when you must detect concurrent writes, for example to keep sibling values for a merge. Each vector holds one entry per writer, so they only stay practical when the set of writers is bounded and stable.
- Hybrid logical clocks fit when you want causal ordering plus timestamps close to wall time. Operators can read them, and storage engines can range-scan them ("everything after 14:02").
When to skip logical clocks
If every key has a single authority, logical clocks add nothing. A partition leader or a database log sequence number already orders that key's writes. Logical clocks also cannot give you linearizability or external consistency, because they only order events that communicated. If a client sees write A complete and then issues write B elsewhere, nothing links the two.
The alternatives each carry a cost:
| Approach | Guarantees | Cost |
|---|---|---|
| Consensus (Raft, Paxos) | Linearizable order per group | A quorum round trip per write. A group without a majority cannot accept writes. |
| Single sequencer | Global total order | Throughput bottleneck and a single point of failure, plus failover complexity. |
| Bounded clock uncertainty | External consistency | Commits wait out the uncertainty window. This depends on tightly disciplined clocks. |
Impact on development cost and engineering time
Most of the cost is in the plumbing. A logical clock only works if every hop carries it, so retrofitting touches each producer, consumer, proxy, queue header mapping and storage layer. A single service that drops the header quietly breaks causality for everything downstream of it. Storing both a wall-clock timestamp for humans and a logical timestamp for ordering also means a schema migration and extra bytes on every event.
Illustrative estimate (assumed numbers, not a benchmark): extra storage per day = added bytes per event × events per day × replication factor. With 16 added bytes, 200 million events per day and 3 replicas: 16 × 200,000,000 × 3 = 9.6 GB/day before compression.
Operators pay as well. A physical component plus a counter is harder to read during an incident than a plain timestamp. Under pressure, teams drift back to ORDER BY created_at and reintroduce the original bug. Someone has to build and maintain tooling that shows logical time next to wall time.
The largest cost is usually not the clock itself. A vector clock tells you two writes are concurrent but not what to do about them. You still need a merge strategy, such as CRDTs, domain-specific rules, or surfacing the conflict to a user. Designing that for each entity is real product and engineering work.
The return is in bugs you stop chasing:
- Last-write-wins no longer discards updates because one node's clock ran ahead.
- Replay and deduplication become deterministic, because order is stored in the data instead of inferred from hosts.
- Incident timelines show what caused what, not which machine's clock was fastest.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to clock skew microservices, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- The dependency graph answers "what does this touch?" in seconds instead of a manual audit.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- You can size a change's blast radius before merging, avoiding surprise cross-team firefighting.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.