Lamport Timestamps and Vector Clocks

Distributed Systems advanced Building with AI ~38 min read

Overview

Distributed systems do not have one obvious order of events. A message may be delayed, a process may pause, and two nodes may perform updates without knowing about each other. Physical clocks help humans reason about logs, but they cannot prove causality unless the system has strong synchronization guarantees. Lamport timestamps and vector clocks solve the ordering problem from inside the distributed computation itself.

Lamport’s 1978 paper introduced the happens-before relation: events are ordered when they occur in one process sequence, when one sends a message that another receives, or by transitivity. A scalar Lamport clock assigns an integer to every event so that if event A happened before event B, A’s timestamp is smaller than B’s. That is enough to build a deterministic total order by adding a process-id tie breaker, but it cannot tell whether two events were truly concurrent.

Vector clocks extend the idea by carrying one counter per participant. They preserve enough information to compare causal histories directly: one vector is before another if every component is less than or equal and at least one is strictly smaller. If neither vector dominates the other, the events are concurrent. This is why Dynamo-style databases can return siblings for application reconciliation instead of silently overwriting one concurrent write with another.

The practical lesson is narrow but important: use scalar logical clocks when you need a cheap causal-respecting order, use vector clocks when you must detect concurrency, and use hybrid logical clocks only when you also need timestamps close to wall-clock time.

Core Concepts

Happens-Before

Happens-before is a partial order over events. Within one process, earlier local events happen before later local events. A send happens before the matching receive. If A happens before B and B happens before C, then A happens before C. Events that cannot be connected through those rules are concurrent, even if wall-clock timestamps appear to put one earlier than the other.

Lamport Scalar Clocks

Each process keeps a counter. It increments the counter before each local event and attaches the counter to outgoing messages. On receive, it sets its counter to at least the received timestamp plus one. This satisfies Lamport’s clock condition: causal order implies timestamp order. The converse is false, so a smaller Lamport timestamp does not prove causality.

Vector Clocks

A vector clock stores a map from replica identity to counter. A process increments its own component on local events and merges received vectors component-wise using maximum. This gives a compact causal summary of the event’s known past. For a fixed membership set of N replicas, each timestamp has O(N) components.

Key Concept: Total Order vs Causality Detection

Lamport clocks can impose a total order by sorting on (timestamp, process_id), but that order may arbitrarily order concurrent events. Vector clocks preserve the partial order so the system can distinguish “definitely older”, “definitely newer”, and “concurrent, needs merge”.

How It Works

Lamport Clock Rules

The scalar algorithm is cheap: one integer per process and one integer per message. It is useful for log ordering, deterministic request queues, and protocols that need all nodes to break ties the same way after causality has been respected.

type LamportClock struct {
    node string
    t    uint64
}

func (c *LamportClock) Tick() uint64 {
    c.t++
    return c.t
}

func (c *LamportClock) Send() uint64 {
    return c.Tick()
}

func (c *LamportClock) Receive(messageTime uint64) uint64 {
    if messageTime > c.t {
        c.t = messageTime
    }
    c.t++
    return c.t
}

func Less(aTime uint64, aNode string, bTime uint64, bNode string) bool {
    if aTime != bTime {
        return aTime < bTime
    }
    return aNode < bNode
}

Vector Merge and Compare

Vector clocks need a merge operation and a comparison operation. Merge takes the component-wise maximum. Compare returns before, after, equal, or concurrent. The concurrent case is the value that scalar clocks cannot represent.

type VectorClock map[string]uint64

func (vc VectorClock) Tick(node string) VectorClock {
    next := vc.Copy()
    next[node]++
    return next
}

func (vc VectorClock) Merge(other VectorClock) VectorClock {
    next := vc.Copy()
    for node, value := range other {
        if value > next[node] {
            next[node] = value
        }
    }
    return next
}

func Compare(a, b VectorClock) string {
    aLess := false
    bLess := false
    nodes := unionKeys(a, b)
    for _, node := range nodes {
        if a[node] < b[node] {
            aLess = true
        }
        if b[node] < a[node] {
            bLess = true
        }
    }
    switch {
    case aLess && !bLess:
        return "before"
    case bLess && !aLess:
        return "after"
    case !aLess && !bLess:
        return "equal"
    default:
        return "concurrent"
    }
}

Practical Applications

Event Logs and Distributed Tracing

Lamport timestamps let logs from many processes be sorted into an order that never contradicts message causality. That does not make the order a real timeline, but it makes debugging easier because a receive cannot appear before the send that caused it.

Leaderless Replication

Dynamo used vector clocks to track multiple versions of an object. If a new write descends from an older version, the old version can be discarded. If two writes are concurrent, both versions may be returned to the application for reconciliation. The shopping-cart example is canonical: merging by union preserves additions but can make deleted items reappear unless deletion intent is represented explicitly.

Causal Consistency

Session tokens, dotted version vectors, dependency vectors, and vector-clock variants let replicas delay visibility until causal dependencies are present. This supports read-your-writes and prevents dependent objects from appearing before their prerequisites.

Implementation Patterns

Store Version Vectors with Values

For a replicated key-value store, each stored sibling should carry its vector. A write based on a prior read should merge the client context, increment the coordinator component, and then discard dominated siblings while preserving concurrent siblings.

type VersionedValue struct {
    Value []byte
    Clock VectorClock
}

func Put(existing []VersionedValue, clientClock VectorClock, writer string, value []byte) []VersionedValue {
    newClock := clientClock.Tick(writer)
    out := []VersionedValue{{Value: value, Clock: newClock}}
    for _, old := range existing {
        switch Compare(old.Clock, newClock) {
        case "before", "equal":
            continue // old version is superseded
        case "after":
            return existing // stale write; caller should reread and merge
        case "concurrent":
            out = append(out, old)
        }
    }
    return out
}

Bound Metadata Deliberately

Plain vector clocks grow with the number of participants that have written a key. Systems cap entries, prune inactive writers, or use dotted version vectors to keep per-object metadata bounded. Pruning can lose causality information, so it should be measured as a correctness trade-off rather than hidden as compression.

Performance Considerations

Common Pitfalls

Further Reading