Why wall-clock timestamps fail at ordering events

NTP keeps clocks close, not identical. Every host drifts between corrections, so any two services disagree by some skew ε. That skew is usually small but never zero, and it can grow without bound if a host loses its time source. Wall clocks also move backward. When NTP steps a clock to fix a large offset, or a VM resumes after a pause or live migration, a later reading can be smaller than an earlier one. Leap seconds make it worse: some systems repeat a second while others smear it across hours, so two hosts can disagree by up to a second during the window.

Monotonic clocks prevent backward jumps, but only within one process on one boot. Their readings have no shared origin, so comparing them across machines is meaningless.

Effect stamped before cause

Illustrative estimate (assumed numbers, not a benchmark). Assume Order's clock runs ε = 40 ms ahead of Payment's, and network delay is d = 15 ms. Order emits OrderPlaced at its local 10:00:00.100. Payment receives it and immediately emits PaymentCaptured at its local 10:00:00.100 + d − ε = 10:00:00.075. The effect is stamped 25 ms before its cause. In general, the inversion happens whenever ε > d + processing time.

Silently lost writes

Last-write-wins on timestamps turns this into data loss. If two services update the same record and the one with the slower clock writes second, its newer value carries an older timestamp and is discarded. The system raises no error and records no conflict. The update is simply gone.

Use wall time for display, logs and audit trails. Do not use it to decide order.

 (1) Effect stamped before cause: Order clock 40 ms ahead, delay 15 ms
+----------------------------+          +----------------------------+
| Order service (+40 ms)     |          | Payment service (+0 ms)    |
| local 10:00:00.100         |          | local 10:00:00.075         |
| emit OrderPlaced (cause)   |--15 ms-->| emit PaymentCaptured       |
|                            |          | (effect)                   |
+----------------------------+          +----------------------------+
              |                                        |
              v                                        v
+--------------------------------------------------------------------+
| Event log sorted by timestamp                                      |
|   10:00:00.075  PaymentCaptured   <- effect sorts first            |
|   10:00:00.100  OrderPlaced       <- cause sorts second            |
+--------------------------------------------------------------------+

 (2) Last-write-wins on timestamps silently drops a write
+----------------------------+          +----------------------------+
| Service A (+40 ms)         |          | Service B (+0 ms)          |
| real t=0 ms                |          | real t=10 ms (later)       |
| SET x=v1  ts=.540          |          | SET x=v2  ts=.510          |
+----------------------------+          +----------------------------+
              |                                        |
              v                                        v
+--------------------------------------------------------------------+
| LWW store keeps max(ts): .540 > .510, so x = v1                    |
| v2 was written later but is discarded:                             |
| no error, no conflict, update lost                                 |
+--------------------------------------------------------------------+

Illustrative estimate with assumed numbers (40 ms skew, 15 ms delay): clock skew makes an effect sort before its cause and lets last-write-wins discard a later write without any error.

Who should use these techniques

The techniques in this article are for teams whose services exchange events or replicate state, and whose consumers make decisions based on the order those events arrive in. Concretely, they apply if any of these is true:

  • Multiple producers write about the same entity or workflow. Two services, or two instances of one service, can update the same record, and you currently resolve conflicts by comparing timestamps.
  • Consumers build projections or read models from events. A projection that applies events out of order ends up in a state that never existed at the source.
  • Cross-service workflows have causal dependencies. A refund, cancellation or compensation step must never be processed before the event it responds to.
  • You run multi-region or multi-writer replication. Replicas accept writes independently and must later agree on which one wins or detect that both happened concurrently.

They are usually unnecessary if every entity has a single writer and a single ordered stream, if consumers are fully idempotent and their operations commute (applying events in any order gives the same result), or if a single database transaction already serializes the writes you care about. In those cases, adding clock metadata brings propagation and debugging cost without changing outcomes.

Which ordering guarantee do you actually need?

Before picking a mechanism, decide what "ordered" has to mean for your consumers. Three guarantees cover almost every case, and each step up costs more than the last.

Per-entity order means events about one order, account or cart arrive in the sequence they were produced. Most aggregates need nothing more, because their invariants live inside a single entity. A consumer rebuilding an account balance does not care how account A's events interleave with account B's.

Causal order means that if event X could have influenced event Y, every consumer sees X before Y, even when they come from different services. You need this when a downstream decision depends on state written elsewhere, such as a refund that must never be processed before the payment it reverses.

Total order means every consumer sees every event in one identical sequence. Few domains need it. A matching engine or a global ledger with cross-account invariants are typical examples.

Guarantee Cheapest mechanism Typical need Avoid heavier options when
Per-entity Sequence number per entity, partitioned by entity key Aggregates, CRUD services, projections Invariants stay within one entity
Causal Hybrid logical or vector clocks propagated with messages Cross-service workflows, replicated state Consumers can tolerate concurrent events in either order
Total Single partition or sequencer Matching, global ledgers Always, unless a cross-entity invariant requires it

Total ordering has a structural price. A single sequencer or partition caps throughput at what one node can handle. It concentrates load on one hot spot. It also makes every producer depend on that one component, so its outage halts all writes.

How it works: ordering by causality instead of time

All the mechanisms below share one idea. Instead of asking "which event has the earlier timestamp?", they ask "could this event have known about that one?" That relationship, called happened-before, is defined by information flow, not by clocks: A happened before B if A and B occurred in the same process with A first, or if A sent a message that B's process received before B, or through a chain of such steps.

Information flow can be tracked exactly, because it travels with the messages themselves. The overall approach has three parts:

  1. Producers attach ordering metadata. This is a per-entity version number, a logical clock value, or both. The metadata is assigned under a rule that guarantees it increases along every causal chain, regardless of what any wall clock says.
  2. Every hop propagates and merges the metadata. A service that receives a message folds the incoming value into its own state before producing anything new, so whatever it emits is ordered after what it received.
  3. Consumers decide using the metadata, not arrival time. They apply the next expected version, skip duplicates, buffer events that arrive after a gap, and, with vector clocks, detect when two updates are genuinely concurrent and need a merge.

Wall time can still appear in the metadata, as it does in hybrid logical clocks, but only as a hint that keeps values readable and close to real time. Correctness never depends on it. The following sections cover each part in turn: per-entity sequence numbers, the logical clock algorithms, propagation across hops, and consumer-side handling.

Per-entity event sequence numbers: the default choice

Most systems don't need a global order. They need every change to a single entity, such as an order, account or shipment, applied in the order it happened. Scoping the sequence to one aggregate removes the global bottleneck. Each aggregate becomes its own small sequencer, and throughput scales with the number of entities.

The mechanism is a version number stored with the aggregate and incremented on every write. A writer reads the aggregate at version n and submits its change with an expected version of n. The store accepts the write only if the current version is still n, then sets it to n+1. If another writer got there first, the check fails, and the caller reloads and retries. This optimistic concurrency check prevents two writers from both producing version 6. Without it, the sequence is a label, not a guarantee.

Consumers track the last version they applied for each aggregate ID:

  • Next version (n+1): apply it.
  • Version at or below the last applied: it is a duplicate from a redelivery. Acknowledge it and skip it.
  • Version above n+1: there is a gap. Buffer the event, or pause and alert, but don't apply it.

This only holds end to end if the broker keeps each aggregate's events in order. Use the aggregate ID as the partition key. Any other key can spread one entity's events across partitions that are consumed in parallel.

The limitation is scope. Version 7 of an order says nothing about version 3 of a payment. Workflows that span aggregates or services need causal metadata, covered next, or a saga whose steps tolerate arriving out of order.

from dataclasses import dataclass

@dataclass
class Event:
    aggregate_id: str   # also the broker partition key, so one aggregate's events stay in order
    version: int        # assigned by the store's expected-version check; first event is 1
    payload: dict

class PerAggregateConsumer:
    def __init__(self, apply_fn):
        self.apply_fn = apply_fn
        # In production, persist last_applied in the same transaction as the applied change.
        self.last_applied: dict[str, int] = {}
        self.pending: dict[str, dict[int, Event]] = {}  # events held back by a gap

    def handle(self, e: Event) -> str:
        last = self.last_applied.get(e.aggregate_id, 0)
        if e.version <= last:
            return "duplicate"  # redelivery: acknowledge and skip
        if e.version > last + 1:
            # Gap: buffer and don't apply. Alert if it stays open too long.
            self.pending.setdefault(e.aggregate_id, {})[e.version] = e
            return "gap"
        self._apply(e)
        # The gap may have closed: drain buffered events that are now contiguous.
        buffered = self.pending.get(e.aggregate_id, {})
        while (nxt := buffered.pop(self.last_applied[e.aggregate_id] + 1, None)):
            self._apply(nxt)
        return "applied"

    def _apply(self, e: Event) -> None:
        self.apply_fn(e)
        self.last_applied[e.aggregate_id] = e.version

c = PerAggregateConsumer(lambda e: print("apply", e.aggregate_id, e.version))
for v in (1, 3, 2, 2):  # 3 arrives early, then 2 closes the gap, then 2 is redelivered
    print(v, c.handle(Event("order-1042", v, {})))

A consumer tracks the last applied version for each aggregate. It applies version n+1, skips redelivered versions at or below n, and buffers events that arrive after a gap until the missing version shows up.

How Lamport, vector and hybrid logical clocks work

Lamport clocks

Each process keeps one integer. It increments the counter before every local event or send and attaches the value to outgoing messages. On receive, it sets its counter to max(local, received) + 1. Information only flows forward through messages, and every hop raises the counter, so if event A happened-before event B, then L(A) < L(B). The reverse does not hold. Two services that never communicated can produce counters 5 and 9, and the comparison means nothing. Lamport clocks give you a total order that respects causality, but they cannot tell you whether two events actually conflict.

Vector clocks

A vector clock keeps one counter per node. A node increments its own entry on each event and, on receive, takes the element-wise maximum and then increments its own entry. To compare two vectors, check every entry. If every entry of A is ≤ the matching entry of B and at least one is strictly less, A happened before B. If neither vector dominates, the events are concurrent. That concurrent result is what you pay for. It tells you that a real conflict exists, which you then resolve with an application-level merge or a CRDT. The cost is that vector size grows with the number of participants.

Hybrid logical clocks

An HLC is a pair (l, c): l tracks the largest physical time seen, and c is a counter that breaks ties.

  • Local or send event: set l' = max(l, now). If l' equals the old l, increment c. Otherwise reset c to 0.
  • Receive (lm, cm): set l' = max(l, lm, now). Set c to one more than the counter from whichever source or sources supplied l'. If l' came from both the local clock and the message, use max(c, cm) + 1. If it came only from now, reset c to 0.

l stays close to real time, so the values remain readable. The pair also has a fixed size. Packing it into a single integer, with physical time in the high bits and the counter in the low bits, gives you sortable event IDs or MVCC timestamps.

import time

class HLC:
    """Hybrid logical clock: l = max physical time seen (ms), c = tie-break counter."""

    def __init__(self, now_ms=lambda: time.time_ns() // 1_000_000):
        self.now_ms = now_ms  # injectable so tests can simulate clock skew
        self.l, self.c = 0, 0

    def send(self):
        # Local or send event: advance l to wall time if it moved forward.
        old_l = self.l
        self.l = max(old_l, self.now_ms())
        self.c = self.c + 1 if self.l == old_l else 0
        return (self.l, self.c)

    def receive(self, lm, cm):
        # Receive (lm, cm): l' is the max of local l, message l, and wall time.
        old_l, old_c = self.l, self.c
        self.l = max(old_l, lm, self.now_ms())
        if self.l == old_l == lm:
            self.c = max(old_c, cm) + 1  # both sources supplied l'
        elif self.l == old_l:
            self.c = old_c + 1           # only local l supplied l'
        elif self.l == lm:
            self.c = cm + 1              # only the message supplied l'
        else:
            self.c = 0                   # wall time moved past both
        return (self.l, self.c)

def encode(l, c):
    # Fixed 64-bit layout: 48 bits of ms time, 16 bits of counter.
    # Integer order matches (l, c) order, so it works as a sortable ID
    # or an MVCC timestamp. Real systems must handle counter overflow.
    assert c < (1 << 16), "counter overflow"
    return (l << 16) | c

a, b = HLC(lambda: 1_700_000_000_500), HLC(lambda: 1_700_000_000_000)
msg = a.send()                     # (1700000000500, 0)
reply = b.receive(*msg)            # (1700000000500, 1)
assert encode(*reply) > encode(*msg)

A minimal Python sketch of the HLC send and receive rules, plus a 48/16-bit packing that turns (l, c) into a sortable 64-bit integer, showing that causal order is preserved even when the receiver's wall clock lags behind.

Implementing clock propagation and consumer-side handling

Carrying the clock across hops

Put the clock or sequence number in a message header or RPC metadata field, not in the payload. Middleware can then read and update it without deserializing business data. Both directions need an update. On send, the service ticks its local clock and stamps the outgoing message. On receive, it merges the incoming value before doing any work. If the receive-side merge is skipped, any event the handler emits can be ordered before the event that caused it.

Async hops through a queue follow the same rule: the consumer merges on dequeue, not when the message was enqueued. Retries must resend the original stamp. If a retry gets a new stamp, it looks like a new event. In fan-out, every copy carries the same value. Each downstream consumer orders it against its own stream, not against the other copies.

Consumer-side handling

The most useful guard is an idempotent upsert keyed on version. The write succeeds only when the incoming version is greater than the stored one. Stale or duplicate deliveries then match zero rows, and the consumer acknowledges them and moves on. The example that follows uses a conditional update for this.

When version 7 arrives but the store holds 5, there is a gap. Hold 7 in a reorder buffer with a bounded wait. If 6 still has not arrived when the wait expires, you have three options: apply 7 anyway, fetch current state from the source, or park the entity for review. Which one is right depends on the domain. A stock count can usually accept a skipped intermediate update. A ledger cannot. "Late" is therefore a business rule about how long to wait and what to do afterward, not a comparison between two timestamps.

-- Projection table: one row per entity, with the last applied version.
CREATE TABLE IF NOT EXISTS stock_level (
    sku         text        PRIMARY KEY,
    quantity    integer     NOT NULL,
    version     bigint      NOT NULL,
    updated_at  timestamptz NOT NULL DEFAULT now()
);

-- Version-guarded idempotent upsert (PostgreSQL).
-- $1 = sku, $2 = quantity, $3 = version taken from the message header.
INSERT INTO stock_level AS s (sku, quantity, version)
VALUES ($1, $2, $3)
ON CONFLICT (sku) DO UPDATE
    SET quantity   = EXCLUDED.quantity,
        version    = EXCLUDED.version,
        updated_at = now()
    -- Apply only if the incoming version is newer than the stored one.
    -- A stale or duplicate delivery fails this check and changes nothing.
    WHERE s.version < EXCLUDED.version
RETURNING s.version;

-- The consumer reads the result:
--   one row returned  -> applied; ack the message.
--   zero rows         -> stale or duplicate; ack it and move on, no retry.
--
-- For a domain that must not skip versions, such as a ledger, use a
-- strict guard instead of the one above:
--     WHERE s.version = EXCLUDED.version - 1
-- Zero rows can then mean a stale event or a gap. Compare against the
-- stored version to tell which. On a gap, put the event in the reorder
-- buffer rather than acknowledging it as stale.

A PostgreSQL upsert that writes only when the incoming version is newer than the stored one, so duplicate and stale deliveries are no-ops, with a stricter guard for domains that must not skip versions.

Alternatives to logical clocks

Logical clocks are not the only way to get correct ordering. Several alternatives avoid propagating clock metadata altogether, each with its own cost:

  • Single sequencer or single partition. Route all related events through one log or one assigning node. Order is whatever the sequencer decides, so no clock is needed. The cost is throughput limited to one node and a single point of failure, as described above.
  • Consensus-replicated log. A replicated log built on a consensus protocol such as Raft or Paxos gives a total order that survives node failures. It removes the single point of failure of a plain sequencer but adds a round of coordination to every write, and throughput is still bounded by the log's leader.
  • Serializing in the database. If the events that must be ordered all touch one database, let its transactions and row-level locking do the work. Combined with a transactional outbox, which writes the event in the same transaction as the state change, the event order follows the commit order for that data.
  • Bounded-uncertainty clocks with commit-wait. Some systems measure an explicit error bound on physical time and make a transaction wait out that uncertainty before committing, so timestamp order matches real-time order. This needs tightly controlled time infrastructure and adds latency on every commit, and it is rarely practical to build yourself.
  • Designing for order independence. Commutative and idempotent operations, such as incrementing counters or adding to sets, and CRDTs give the same final state in any delivery order. When the domain allows it, this removes the ordering problem instead of solving it.
  • Sagas with explicit state checks. Instead of relying on delivery order, each step checks the current state of the workflow and rejects or defers actions that are not yet valid, such as a refund for a payment that has not been recorded.

In practice these combine with the mechanisms above. A common shape is per-entity sequence numbers for most data, a single partition for the few flows that need total order, and commutative designs wherever the domain permits.

Trade-offs and when to avoid each approach

Each mechanism fails in a specific way, and that failure mode should drive the choice more than its theoretical strength.

Mechanism What grows Failure mode Overkill when
Per-entity sequence numbers One counter per entity No ordering across entities Never, as a baseline
Vector clocks One entry per participant Pruning creates false conflicts Writes have a single owner
Hybrid logical clocks Logical counter under skew One fast node drags all clocks forward You only need per-entity order
Reorder buffers Latency and memory per key Stalls on gaps until timeout Consumers are idempotent and commutative

Vector clocks grow with the number of participants. With autoscaling, every new pod is a new participant, so you must prune entries. Pruning discards causal history, and two causally related events can then compare as concurrent. That produces conflicts nobody actually caused.

HLCs assume bounded skew. A node whose clock runs ahead pushes its physical component into every clock it touches. Until real time catches up, the other nodes advance only the logical counter. If you do adopt HLCs, reject or alert on messages beyond a maximum offset.

All logical clocks share a blind spot: hidden channels. A write to a shared database, a cache entry, or a user who reads one service and then acts in another carries no clock, so causality between those events is invisible.

Reorder buffers trade correctness for latency and memory. Every gap holds back later events until it fills or the timeout fires.

Often the simpler option is enough. A single sequencer, or routing all related events through one partition, gives you total order without clock metadata. It costs throughput and creates a single point of contention.

Operational guardrails and impact on cost and engineering time

Guardrails that keep clocks honest

Hybrid logical clocks assume physical time is roughly right. They do not fix skew; they hide it. Monitor clock offset on every host, not just a sample, and alert before it reaches your maximum tolerated skew. Set that ceiling explicitly and configure HLC nodes to reject or alert when a remote physical component is further ahead than that ceiling allows. Without the check, one host with a clock hours fast drags every peer's logical clock forward, and nothing recovers until real time catches up.

Log the logical clock alongside wall time in every event and log line. During an incident, wall time tells you roughly when something happened. The logical value tells you what happened before what, even when two hosts disagree on the time.

What it costs to build

The main expense is propagation. Every HTTP client, gRPC interceptor, producer, consumer, and broker hop must carry the clock header. If one component drops the header, ordering degrades with no error. Contract tests that assert headers survive each hop are worth the effort. You also pay in schema and cognitive overhead: each record now stores both physical and logical time, and engineers must know which one to sort by.

Where it saves time

You get fewer silent lost writes from last-writer-wins on bad timestamps, and incidents are faster to reconstruct. The biggest saving comes from choosing the cheapest guarantee that is sufficient. Per-entity sequence numbers avoid the coordination and debugging cost of mechanisms you didn't need.

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 microservice event ordering clock skew, 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.
  • Changing a route's URL or method automatically notifies the services that consume it — no hunting through repos for callers.

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.
  • Consumers learn about a breaking change at review time rather than from production errors.
  • 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.