Back to Blog
distributed-systemsvector-clocksgocausality

Vector Clocks, Explained With a Budget

Why timestamps can't tell which edit came first across machines, how vector clocks can, and the bugs I hit wiring them in.

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}

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
	}
}

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.