Appearance
Last-write-wins on purpose
Two people edited the same shopping item while offline. One changed the name. The other checked it off. Both clients uploaded later. The final document kept only one person's version, so either the new name or the checked state disappeared.
Last-write-wins had done exactly what it promised. The mistake was treating two independently changing facts as one conflict unit.
LWW is often dismissed as crude. It is also small, deterministic, easy to mirror on client and server, and adequate for a large class of records. The right question is not whether LWW loses concurrent writes. It does. The question is whether that loss is acceptable for this document shape.
Concept map
text
incoming document + existing document
|
v
compare logical version
| newer | older
v v
replace reject
|
equal timestamp
|
stable writer tie-break
convergence depends on every replica using the same comparisonLWW chooses one complete version. It does not combine fields.
Terms
Conflict unit. The smallest value the merge rule chooses as a whole.
Timestamp. A version component intended to order writes. In a browser it often comes from wall-clock milliseconds.
Tie-breaker. A stable secondary value used when timestamps match.
Stale write. An incoming version that loses against the current version.
Convergent rule. A rule that gives the same answer regardless of delivery order, assuming all replicas eventually see the same versions.
Clock skew. Difference between device clocks.
The complete rule fits in a few lines
ts
type Versioned = {
updatedAt: number;
writerId: string;
};
function incomingWins(
incoming: Versioned,
existing?: Versioned
): boolean {
if (!existing) return true;
if (incoming.updatedAt !== existing.updatedAt) {
return incoming.updatedAt > existing.updatedAt;
}
return incoming.writerId > existing.writerId;
}The tie-breaker matters. Without it, two replicas that receive equal-timestamp writes in opposite order can settle on different values.
Mirror the function in the client and server, then run both against the same fixture table. A one-character difference in comparison direction is enough to create views that flip after acknowledgement.
Use a monotonic local stamp per process:
ts
let lastStamp = 0;
function nextStamp(now = Date.now()) {
lastStamp = Math.max(now, lastStamp + 1);
return lastStamp;
}This prevents two rapid writes on one device from sharing a timestamp. It does not fix clock skew between devices.
Choose document boundaries before choosing LWW
LWW works well when concurrent edits are naturally exclusive:
- A user's chosen theme.
- The current title of a card.
- The selected option in a setting.
- A device's latest cursor.
- A record whose entire meaning changes together.
It works poorly when one document contains independent values:
json
{
"id": "item-7",
"text": "Buy oats",
"done": false,
"notes": "Gluten free",
"assignee": "Sam"
}If four people can edit those fields independently, whole-document LWW creates unnecessary conflicts.
Split by ownership or change rate. One document per item may be fine if edits rarely overlap. One document for an entire list is usually not. A state store can persist each top-level key as its own LWW document, which gives independent keys independent conflict resolution without adding a new server merge mode.
This choice has a cost. More documents mean more metadata, reads, log entries, and quota usage. Use the coarsest unit that preserves the edits users expect to keep.
Deletion needs a version too
Deleting a record outright allows an old offline copy to upload later and recreate it. Store a tombstone with the same ID, timestamp, and writer tie-breaker.
Keep tombstones longer than the maximum credible offline period. Thirty days is a reasonable product choice for some small apps, but it is not a universal truth. A field device that can remain offline for a quarter needs a longer window or a server-side generation boundary.
When a tombstone expires, remove it from storage and quota accounting. Test the cleanup path. A system that retains tombstones forever eventually turns delete-heavy workloads into storage leaks.
Clock policy is part of the design
Client clocks are convenient because a write can be ordered while offline. They are also untrusted.
A device set one year into the future can dominate a document until real time catches up. Options include:
- Clamp accepted timestamps to a bounded amount ahead of server time.
- Replace client time with server receipt time, accepting that upload order wins.
- Use hybrid logical clocks, which preserve causal progress with more metadata.
- Let a server-issued epoch invalidate implausible old or future versions.
For low-risk shared tools, a future-time clamp plus a deterministic writer tie-break is often enough. Security-sensitive decisions should not depend on client LWW at all.
Why use LWW anyway
The rule is easy to inspect. Replaying a version twice changes nothing. Delivery order does not matter. The server can reject stale writes and return the current winner. The client can update its local replica immediately without waiting for a custom merge service.
That simplicity is valuable when the product needs ordinary shared JSON, not collaborative text.
Alternatives have costs:
Server receipt order. It removes client clock skew, but a device that reconnects last wins even if its edit happened much earlier.
Field-level merge everywhere. It preserves independent fields but needs per-field metadata, deletion semantics, and a way to handle nested values.
A general CRDT library. It can handle richer structures. It adds payload, initialization, storage, compaction, and debugging costs that small apps may not need.
Ask the user on every conflict. This preserves intent in theory and makes routine offline use exhausting.
Failure modes
- The client uses
>=while the server uses>. - Writer IDs are missing, reused unexpectedly, or longer than the protocol permits.
- An acknowledgement overwrites a newer pending local write.
- The outbox clears by document ID without checking which version was sent.
- An old tombstone expires before a sleeping client reconnects.
- A nested object changes as one LWW value although its children are independent.
- A future clock makes later normal writes appear stale.
- Sorting by timestamp alone gives unstable list order.
On acknowledgement, remove an outbox entry only if it still matches the sent version. If the user edited the record during upload, the newer local version must remain queued.
Field checklist
Whole-document LWW is a deliberate trade when one winner is acceptable. When it is not, the data type should say what "merge" means. Chapter 16, When LWW stops working develops those cases. The transport that carries these versions is in Chapter 14.