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:
- Local change: take the current clock and add one to your own counter.
- Receive: merge by taking the maximum of each counter (not the sum: summing would invent changes that never happened).
- 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
Concurrentwith 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.