Last-write-wins conflict resolution over WebSockets #
Two people edit the same task at the same moment — one changes the title, the other the due date — and after both updates propagate, one of the changes has vanished. Or worse, the two clients disagree: each shows its own edit because each applied the other’s update and then its own optimistic value on top. Conflicts are unavoidable once several clients write to shared state over WebSockets, and the simplest resolution rule, last write wins, is also the most commonly misimplemented. Done carelessly, “last” is decided by network arrival order and whole objects overwrite each other. Done well — per field, with a well-defined ordering the server assigns — it is predictable, convergent, and a good default for most collaborative forms and dashboards.
Root cause #
Concurrent edits conflict when two clients start from the same state and each change it before seeing the other’s change. The system then needs a rule to decide the outcome, and every replica must apply the same rule to reach the same result. Three mistakes break that.
Whole-object writes. A client sends the entire task, including fields it did not touch. The later write overwrites the earlier one’s changes to other fields, so editing the title silently reverts someone else’s due date. Arrival order as “last”. Whichever update reaches a given client last wins on that client, but messages arrive in different orders at different clients, so replicas diverge. Client clocks. Using Date.now() from the client as the timestamp lets a device with a skewed clock win every conflict for hours.
Resolution #
Apply last-write-wins per field, and let the server decide the order. Clients send only the fields they changed, tagged with the version they last saw. The server assigns each accepted field write a new, monotonically increasing version from a single counter, stores the version per field, and broadcasts the field with its version. Every client, including the author, applies an incoming field only if its version is higher than the one it holds. Because every replica compares the same server-assigned numbers, every replica converges to the same result regardless of arrival order.
// Shared types
type FieldValue = string | number | boolean | null;
interface FieldState { value: FieldValue; version: number }
type Doc = Record<string, FieldState>;
interface FieldPatch { docId: string; field: string; value: FieldValue; version: number }
// ---------------- Server: assign order, keep per-field versions ----------------
let clock = 0; // one counter per document shard in practice
const docs = new Map<string, Doc>();
export function acceptWrite(docId: string, field: string, value: FieldValue): FieldPatch {
const doc = docs.get(docId) ?? {};
const version = ++clock; // the server's arrival order IS the order
doc[field] = { value, version };
docs.set(docId, doc);
return { docId, field, value, version }; // broadcast this to every subscriber
}
// ---------------- Client: optimistic, then converge on server versions ----------
export class LwwDoc {
private confirmed: Doc = {};
private pending = new Map<string, FieldValue>(); // local edits not yet echoed
constructor(private send: (field: string, value: FieldValue) => void, private render: () => void) {}
edit(field: string, value: FieldValue) {
this.pending.set(field, value); // show it immediately
this.send(field, value); // only the changed field
this.render();
}
receive(p: FieldPatch, fromMe: boolean) {
const cur = this.confirmed[p.field];
if (cur && p.version <= cur.version) return; // stale or duplicate: ignore
this.confirmed[p.field] = { value: p.value, version: p.version };
// Our own echo confirms the pending edit; someone else's newer write replaces it.
if (fromMe || this.pending.get(p.field) !== undefined) this.pending.delete(p.field);
this.render();
}
get(field: string): FieldValue {
return this.pending.has(field) ? this.pending.get(field)! : this.confirmed[field]?.value ?? null;
}
}
The rule for pending edits matters. When a patch for a field arrives while this client has an unconfirmed edit to the same field, the incoming patch has a server version, and the local edit will receive a higher one when the server processes it — so the local edit will win in the end. Dropping the pending value on any incoming patch, as above, briefly shows the other user’s value before this client’s echo arrives. If that flicker matters, keep the pending value until the client’s own echo arrives, since its version is guaranteed to be later; the trade-offs mirror those in optimistic UI rollback on WebSocket nack.
When writes can be accepted by several servers or regions independently, a single counter no longer exists. A hybrid logical clock (physical time plus a logical counter, plus a node id as tiebreaker) gives a total order that respects causality and tolerates clock skew, and is the usual choice for multi-region LWW, as discussed in cross-region WebSocket state sync.
When last-write-wins is the wrong rule #
LWW discards the losing write entirely, which is acceptable when fields are small and independent — a status, a date, a toggle — and a lost edit is obvious and cheap to redo. It is the wrong rule for anything where concurrent edits should combine: text documents (two people typing in the same paragraph), counters (two increments should add up), and collections (two people adding items to the same list). Per-field LWW on a text field throws away one person’s typing. For those, use a data type whose merge preserves both intents: a CRDT or operational transform for text, as in CRDT vs operational transform for real-time editing, commutative increments for counters, and add/remove sets for collections.
A middle ground is LWW with conflict surfacing: accept the write, but if the client’s base version was older than the field’s current version, tell the author their edit overwrote someone else’s, so a human can decide. That keeps the simplicity of LWW while making lost updates visible.
Verification #
Write a convergence test: two simulated clients edit the same field and different fields concurrently, patches are delivered in every possible order, and after delivery both clients must report identical values for every field. Per-field LWW with server versions passes in every permutation; a whole-object or arrival-order implementation fails at least one.
it('converges regardless of delivery order', () => {
const a = acceptWrite('t1', 'title', 'A'); // version 1
const b = acceptWrite('t1', 'title', 'B'); // version 2
const c = acceptWrite('t1', 'due', 'Fri'); // version 3
for (const order of [[a, b, c], [c, b, a], [b, a, c]]) {
const d = new LwwDoc(() => {}, () => {});
order.forEach((p) => d.receive(p, false));
expect([d.get('title'), d.get('due')]).toEqual(['B', 'Fri']);
}
});
In production, count writes whose base version was older than the stored version. That number is your conflict rate; if it is high for a particular field, that field probably needs a merging data type rather than LWW.
Operational checklist #
FAQ #
Is last-write-wins safe for collaborative apps? #
For independent scalar fields, yes — it is simple, convergent and predictable when applied per field with server-assigned versions. For free text and collections, it loses data users expect to keep; use CRDTs or operational transform there.
Why not use client timestamps for “last”? #
Client clocks drift and can be set arbitrarily, so a device with a fast clock would win every conflict. Server-assigned versions or hybrid logical clocks give an order every replica agrees on.
How do I stop my own edit flickering when someone else’s arrives? #
Keep the pending local value until your own echo arrives, since the server will assign it a later version than any patch you received before it. Then apply confirmed values normally.
Does LWW need a server at all? #
Peer-to-peer LWW works with hybrid logical clocks and node ids, which is how many CRDT libraries implement their LWW registers. With a central WebSocket server, the server’s counter is simpler and removes clock concerns entirely.
Related #
- Optimistic UI Rollback on WebSocket Nack — pending edits and their resolution.
- Handling Out-of-Order WebSocket Messages — version guards for delivery order.
- CRDT vs Operational Transform for Real-Time Editing — when edits must merge.
- Cross-Region WebSocket State Sync — ordering with multiple writers.