Offline edits conflict or vanish on reconnect
“Users edit while offline; after reconnecting, their changes overwrite someone else's, get overwritten, or are applied twice.”
THE PRINCIPLE
Offline sync is a distributed system with very long partitions. Without base versions, the server cannot tell an edit from an overwrite.
FIRST MOVES
- Write each edit and its outbox entry in one local transaction; flush the outbox in order when online, retry only transient failures (network, 408, 429, 5xx), and park operations rejected with any other 4xx so one bad edit cannot block the queue forever.
- Give every queued operation a client-generated id and dedupe on the server so reconnect replays apply once.
- Send the base version or ETag with If-Match (strong ETags only: If-Match never matches a W/ weak ETag); on 412, refetch, rebase or merge, and surface real conflicts to the user.
- Check the idempotency record before the precondition, or a replay of an applied edit looks like a conflict.
- Limit last-write-wins to fields where losing a concurrent edit is acceptable, and never trust device clocks for ordering.
- Use a CRDT (Automerge, Yjs) for collaborative text and lists; keep invariants such as balances and stock under server authority.
TOOLS: THEN → NOW
Whole-record PUT on reconnectPer-operation outbox with field-level patches and base versions
Hand-rolled sync over SQLiteSync engines and local-first stores such as Automerge; custom sync remains reasonable for simple models
PATTERN SNAPSHOT
async function flush(op: OutboxOp) {
const res = await fetch(`/api/notes/${op.entityId}`, {
method: "PATCH",
headers: {
"Idempotency-Key": op.id, // replay-safe across reconnects
"If-Match": op.baseEtag, // version the edit was based on
"Content-Type": "application/merge-patch+json",
},
body: JSON.stringify(op.patch),
});
if (res.status === 412) return markConflict(op); // refetch, rebase, or ask
if (res.ok) return ack(op, res.headers.get("ETag"));
if (res.status === 408 || res.status === 429 || res.status >= 500)
throw new Error(`retry later: ${res.status}`); // transient: keep queued
return markFailed(op, res.status); // other 4xx never succeed: park and tell the user
}CLOSE THE AI. EXPLAIN THIS.
Why can an offline edit that the server already applied come back as a 412 conflict on replay, and how do you prevent it?HOW IT WORKS UNDERNEATH
RELATED SYMPTOMS
SOURCES
Guide reviewed