Skip to content

When LWW stops working

Two offline voters each read a poll total of 10 and incremented it to 11. After synchronization, the displayed total was 11.

No write was lost in transport. Whole-document LWW received both versions and chose one. The application had encoded addition as assignment, so the merge rule could not know that both increments should count.

This is the point where changing retry logic does nothing. The data type is wrong.

Match operations to merge rules

text
replace one value            -> LWW register
edit independent fields      -> per-field LWW
add and subtract quantities  -> PN-counter
append immutable events      -> append-only log
join and leave membership    -> tagged OR-Set
edit rich text concurrently  -> sequence or text CRDT

Each choice records enough history to combine concurrent intent. More merge power requires more metadata and more careful cleanup.

These merge modes are design choices to implement and verify. The examples below do not show that the sync runtime from the previous chapters already ships PN-counters, OR-Sets, per-field LWW, or text CRDTs. A runtime supports a mode only when its wire format, storage, client merge, server merge, migrations, and tests all implement the same contract.

Terms

Register. A value with a rule that picks one version, such as LWW.

Join. A merge operation that combines two states.

Commutative. merge(a, b) equals merge(b, a).

Associative. Grouping repeated merges does not change the result.

Idempotent. Merging the same state again changes nothing.

PN-counter. A counter represented by per-writer positive and negative totals.

Observed-remove set. A set whose removals name the unique add operations they have observed. An unseen concurrent add survives.

Immutable log. Records that are appended once and never edited in place.

Per-field LWW

If users edit independent fields of one logical record, store a version stamp per field:

ts
type FieldDoc = {
  id: string;
  values: Record<string, unknown>;
  stamps: Record<string, {at: number; by: string}>;
  deletedAt?: {at: number; by: string};
};

function mergeFields(a: FieldDoc, b: FieldDoc): FieldDoc {
  const out = structuredClone(a);
  for (const [field, value] of Object.entries(b.values)) {
    if (versionWins(b.stamps[field], out.stamps[field])) {
      out.values[field] = value;
      out.stamps[field] = b.stamps[field];
    }
  }
  return mergeDeletion(out, b);
}

This keeps a concurrent title edit and completion toggle. It still treats each field value as atomic. If values.members is an array, concurrent array edits still replace one another.

Deletion policy needs an explicit answer. A record-level delete may win over fields no older than the delete. A later field update may resurrect the record, or the design may forbid resurrection. Pick one and test it.

Counters without read-modify-write

A PN-counter stores each writer's highest positive and negative totals:

ts
type PNCounter = {
  positive: Record<string, number>;
  negative: Record<string, number>;
};

function mergeCounter(a: PNCounter, b: PNCounter): PNCounter {
  return {
    positive: maxByKey(a.positive, b.positive),
    negative: maxByKey(a.negative, b.negative),
  };
}

function value(c: PNCounter) {
  return sum(c.positive) - sum(c.negative);
}

To add three, a device increases only its own positive entry by three. Merge takes the maximum for each writer. Replays are harmless, and concurrent writers both contribute.

The map grows with writer identities. Set a cap, define inactive-writer compaction, or use server-issued shards. A counter with millions of one-time anonymous writers needs a different design.

Logs should stay logs

Chat messages, audit events, moves, and activity feeds often fit immutable records. Give every writer a local sequence and an ID such as writerId:sequence.

The first accepted record for an ID wins. Later attempts to alter the same ID are rejected. Sort by server revision for display or by a documented combination of timestamp, writer, and sequence.

An immutable log avoids the false choice between two edited feed documents. It also creates new work:

  • Retention and pagination.
  • Duplicate and forged ID checks.
  • Per-writer sequence persistence.
  • Moderation or redaction records instead of edits.
  • Compaction into snapshots for long histories.

Logs are a good base for game inputs and collaborative text updates. They are not a free database.

Sets need remove semantics

Representing membership as an array inside one LWW document loses concurrent joins.

A tagged add-wins observed-remove set, usually called an OR-Set, gives every add a unique tag such as deviceId:counter. Its state can be viewed as add records plus a set of removed tags:

ts
type ORSet = {
  adds: Record<string, string[]>;
  removed: string[];
};

Adding "Sam" creates a fresh tag and stores it under that element. Removing "Sam" records every tag for Sam that the remover has observed, or carries equivalent causal context such as a version vector. Merge unions the add tags and removal records. Sam is present while at least one of Sam's add tags is not removed.

This is not a timestamp comparison. If one offline device removes the Sam tags it knows while another device concurrently creates a new Sam tag, the removal cannot name that unseen tag. The concurrent add survives. A later removal that has observed the new tag can remove it.

Add-wins is not automatically correct. Access revocation often needs remove-wins because a concurrent stale add must not restore authority. Never reuse a friendly-list merge rule for security membership.

Tags and removal records cost space. Garbage collection is safe only after the system knows no replica or retained message can reintroduce an old tag. That may require causal acknowledgements, a server-defined retention horizon followed by snapshot reset, or membership tracking for every replica. Dropping tombstones because they look old can resurrect removed elements.

Test algebra, not examples alone

For state-based merge functions, property-style tests should shuffle delivery order and duplicates. Assert commutativity, associativity, and idempotence. Generated states are useful, but even a bounded permutation suite catches surprising mistakes.

Mirror merge code across client and server only when you can prove the implementations agree. A shared generated module is better than copying bodies by hand, provided it runs in both environments.

Why not install a general CRDT immediately?

Rich CRDT libraries are the right answer for collaborative text, nested shared structures, or products where preserving every concurrent edit is central.

They also bring costs:

  • Larger downloads and sometimes WebAssembly initialization.
  • Binary update logs and compaction.
  • Awareness or presence protocols.
  • Harder inspection than plain JSON.
  • New persistence and migration rules.

For a poll, grocery list, or room roster, a few named merge modes can cost less in code and bytes. The trade is that you own their proofs and limits.

Rejected shortcuts include storing a counter as a number, storing a set as an array, and storing a feed as one growing document. Each looks simple until two clients work offline.

Failure modes

  • A counter writer resets its local total after clearing storage and reuses the same identity.
  • A set drops removal metadata too early and an old add returns.
  • Field merge copies internal metadata as user fields.
  • An immutable log accepts an ID whose prefix belongs to another writer.
  • Client and server decorate counter rows differently and write the decoration back.
  • Nested objects in a field are assumed to merge recursively.
  • Metadata growth exceeds document limits.
  • A merge function is commutative but not associative.

Field checklist

Merge rules decide what data survives. They do not decide who may read or write it. Chapter 17, Capabilities in the URL builds a sharing model for small spaces. The limits of whole-document replacement are covered in Chapter 15.

Built from field notes on durable software systems.