Search selfstore
v1.8.21

How sync works

selfstore keeps several copies of the same data in agreement without a server. Each device edits its own copy offline; when the copies meet (through the shared backup file, not a sync service) every device folds the others in, and they all end up identical.

No server means no referee. Two devices change the same record while offline, then meet: someone has to decide who wins, and every device must decide the same way, in any order. That is the whole problem this page explains. The everyday API view lives in Multi-device sync; this is the inside of the machine. The engine is importable on its own as selfstore/sync if you want just the merge, no store.

The clock

You cannot order edits with Date.now(): device clocks disagree, and a phone running five minutes fast would win every conflict. A Hybrid Logical Clock is a timestamp built from three parts:

wall time  |  counter  |  device id
  • wall time keeps it roughly in step with real time;
  • the counter breaks ties within the same millisecond, and keeps rising even if the wall clock jumps backwards, so a clock never goes down;
  • the device id makes the order strict: two devices can never produce the same stamp, so “later wins” is never a coin flip.

It is encoded as a fixed-width string, so comparing two clocks is a plain string comparison, and string order equals edit order. Issuing one is a handful of lines:

/** Issue a clock for a local event. Monotonic per node even if the wall clock moves back. */
export function issue(prev: Hlc | null, node: string, wallNow: number = Date.now()): Hlc {
  const p = prev ? decode(prev) : null;
  const prevWall = p ? p.wall : 0;
  const wall = Math.max(wallNow, prevWall);
  const counter = wall === prevWall && p ? p.counter + 1 : 0;
  return encode({ wall, counter, node });
}

When a device reads a clock from another replica it folds it into its own (receive), so its next stamp is guaranteed to sort after everything it has already seen. Causality survives even when the wall clocks lie.

The merge

Records are matched by their string id. On every local save the engine stamps what changed: a fresh clock for every added or modified record. Change is detected by content hash - nothing is injected into your objects, the metadata lives in a small sidecar that travels with the backup.

A record that disappeared leaves a tombstone, a dated “this was removed”. Deletion must be remembered: a device that never saw the delete would otherwise re-contribute the record on its next merge, and it would come back from the dead everywhere.

At merge time, for each id, the later clock wins. A tombstone competes like any other write, so a delete beats an earlier edit and loses to a later one. What “wins” means is configurable per collection: the whole record (lww-set), field by field (lww-map), append-only union (grow-set), a single value (lww-register), or handed to the app instead of resolved (manual).

Tombstones accumulate - remembering deletions is the price of convergence. The pull-model store can prune them past a horizon (tombstoneHorizonMs), safe as long as the horizon comfortably exceeds the longest a device ever stays offline.

Why you can trust it

“Converge” has a precise meaning, and the fuzz suite checks it on thousands of random, seeded edit histories:

  • order does not matter: merging A then B equals merging B then A;
  • duplicates do not matter: merging the same copy twice changes nothing;
  • grouping does not matter: a three-way merge lands in the same place however you pair it up.

When a case fails it prints its seed, so the exact failing history replays as a fixed regression test forever after.

What it is not

  • Full-state, not delta: every merge works on the whole dataset. Fine at the MB scale, wasteful for very large data.
  • Not a sequence CRDT: for live collaborative text, store a Yjs or Automerge document as a binary file in the snapshot and let the store carry and sync it.