DEV Community

Cover image for Vector Clocks, Explained With a Budget
Anton Lungameni
Anton Lungameni

Posted on

Vector Clocks, Explained With a Budget

In short: When the same data is edited on several devices, "keep the newest" is
unreliable, because device clocks disagree and messages arrive late. Vector clocks record
what each device had seen, so a system can tell a genuine conflict from an update that
simply arrived late. I explain them with a budget, plus three bugs that taught me to handle
all four possible outcomes.

My budget lives on a desktop, a laptop and a VPS. Suppose the grocery limit is edited on the
laptop, and later a change to the same limit arrives from the desktop. Should the incoming
change replace the one I have?

The obvious answer is "compare timestamps, keep the newer one". It's wrong, for two reasons:

  • Clocks disagree. The laptop's clock can be minutes off the desktop's. "Newer by the timestamp" can mean "made on the machine whose clock runs fast".
  • Arrival order isn't creation order. A change made yesterday can arrive after a change made today, if the machine that made it was offline.

What I actually need to know is not when each change happened, but whether one change
knew about the other. That's what a vector clock records.

A vector clock is a counter per machine

Every change carries a small map: for each machine, how many of that machine's changes had
been seen when this change was made.

{desktop: 2, laptop: 1}
Enter fullscreen mode Exit fullscreen mode

reads: "this change was made by a machine that had seen the desktop's first two changes and
the laptop's first one."

Three operations are all there is:

  1. Local change: take the current clock and add one to your own counter.
  2. Receive: merge by taking the maximum of each counter (not the sum: summing would invent changes that never happened).
  3. Compare two clocks, counter by counter.

Comparing: four answers, not two

func (vc VectorClock) Compare(other VectorClock) ClockRelation {
    hasLess, hasGreater := false, false
    keys := make(map[string]struct{})
    for nodeID := range vc {
        keys[nodeID] = struct{}{}
    }
    for nodeID := range other {
        keys[nodeID] = struct{}{}
    }

    for nodeID := range keys {
        if vc[nodeID] < other[nodeID] { // a missing key reads as 0
            hasLess = true
        } else if vc[nodeID] > other[nodeID] {
            hasGreater = true
        }
    }

    switch {
    case hasLess && hasGreater:
        return Concurrent
    case hasLess:
        return Before
    case hasGreater:
        return After
    default:
        return Equal
    }
}
Enter fullscreen mode Exit fullscreen mode

Comparing over the union of both clocks' keys matters: a machine missing from one
clock counts as zero, and both sides reach the same answer however the maps are built.

Stored Incoming Relation What it means for the grocery limit
{desktop:1} {desktop:2} Before The incoming edit saw mine and changed it further: apply it
{desktop:2} {desktop:1} After The incoming edit is older, arriving late: ignore it
{desktop:2} {desktop:2} Equal Same edit, delivered twice: ignore it
{desktop:1} {laptop:1} Concurrent Neither saw the other: a real conflict

Note the last row. {laptop:1} isn't "less" than {desktop:2} just because 1 < 2. A clock
is newer only if it's at least as large in every counter. One bigger number means
nothing.

Bug 1: treating it as a yes/no question

My first version of the sync handler asked only "is this a conflict?" Anything that
wasn't concurrent got applied. That merged two very different cases:

  • Before: the incoming edit is newer. Applying it is right.
  • After: the incoming edit is older, delivered late. Applying it silently reverts my budget to stale data.

This is precisely the situation vector clocks exist for, and my code threw the answer away.
The fix was to handle all four relations explicitly.

Bug 2: comparing a change to itself

An early version compared the incoming change with... the incoming change. A clock is always
Equal to itself, so the conflict branch could never run. The code compiled, the tests
passed, and conflict detection was dead code. The tests only checked cases where no conflict
was expected, so nothing noticed.

Bug 3: a local edit that didn't move forward

When I make a change on the laptop, the new clock must come after everything the laptop
already has for that item. The rule is: copy the item's current clock, then increment
your own counter.

I hit both ways of getting this wrong:

  • Starting from an empty clock. My second edit to an item looked Concurrent with my first, a conflict with myself.
  • Forgetting to copy. In Go, a map inside a struct is shared when the struct is copied. Incrementing "my copy" of the clock incremented the stored one too, so old and new compared Equal, and the other machine ignored the edit.

Where this shows up in real systems

  • Offline-first apps. Field-service, inspection, sales and healthcare apps used where connectivity is patchy all face the same question when a device reconnects: is this change newer, older, or in conflict with what the server has?
  • Replicated databases. Amazon's Dynamo paper used vector clocks to detect conflicting writes across replicas, and Riak built on the same idea.
  • Anywhere "last save wins" is the default. Multi-device note-taking, CRMs, shared spreadsheets: without causality tracking, a late-arriving save silently overwrites newer work, and users call it data loss.

What I took away

  • Wall-clock time answers "when"; distributed systems usually need "what did you know".
  • A comparison with four outcomes deserves four branches. Collapsing them is how stale data wins.
  • When a test only covers the cases where nothing interesting happens, it can pass forever while the interesting code never runs.

Next: Retries Are Normal: Building an Idempotent Sync API.


About this series: I'm learning distributed systems by building a real application, with an
AI assistant as coach and pair programmer. It explains, reviews and sometimes writes code; I
verify every claim against tests and measurements, and every bug and number here is real.

I write about building reliable software in Go, from the code up. If you're working on similar
problems, I'd like to hear from you: get in touch or connect with me on LinkedIn.

Top comments (0)