This guide sits under PWA Offline Recovery & Service-Worker Resilience, which covers the broader set of failure modes — stuck workers, replayed requests, precache routing — that surface once an app starts working offline.

The problem: reconnecting doesn’t mean your data agrees #

A user opens a record in a PWA while offline — a task, a cart line, a document. They edit it. The service worker keeps the app shell alive and IndexedDB keeps the edit durable across reloads, so nothing about the experience signals danger. Ten minutes later a colleague edits the same record from another device, and their change reaches the server successfully because they were online the whole time.

When the first user’s device regains connectivity, the naive sync strategy pushes the local copy to the server as-is: PUT /records/42 with whatever is sitting in IndexedDB. If the server blindly accepts the newest write, the colleague’s change simply vanishes — overwritten by a payload that was constructed from data that is now stale. Nothing errors. Nothing logs a warning. The response is a clean 200 OK, because from the server’s perspective a valid record was written. This is the classic lost-update anomaly, and it is invisible until someone notices their edit “reverted” for no reason.

User-visible consequence: fields the colleague changed silently revert to the offline user’s stale values, or — if the server instead rejects the stale write outright — the offline user’s edits appear to vanish entirely on reconnect, with no error message explaining why. Both outcomes erode trust in the app faster than an outright crash would, because there is no obvious failure to report.

Rejecting the stale write outright isn’t automatically the safer choice, either, unless the client has a defined story for what happens next. A 409 Conflict response that the client silently retries with the same stale payload just delays the same anomaly by one round trip. And a 409 the client discards without surfacing anything looks, from the user’s seat, identical to the edit never having been saved at all. Treat “the app just reconnected” as “about to discover whether my base state is still accurate,” never as “safe to push blindly” — the entire design in this guide follows from that one shift in assumption.

The fix is not “sync faster.” It’s tracking what the client’s edit was based on so the server (and, when needed, the client) can tell an update from a conflict.

Prerequisites #


Core implementation: an IndexedDB outbox #

Every mutation does two things inside a single transaction: it updates the local records store optimistically, so the UI reflects the edit immediately, and it appends an entry to an outbox store describing the intent — operation, entity id, the version the edit was based on, and the payload. A separate routine drains the outbox whenever the app comes back online.

import { openDB, type IDBPDatabase } from 'idb';

interface OutboxEntry {
  id: string;                              // uuid; doubles as an idempotency key
  op: 'update' | 'delete';
  entityId: string;
  baseVersion: number;                     // server version this edit assumed
  payload: Record<string, unknown> | null; // null for delete ops
  createdAt: number;
}

interface RecordRow {
  id: string;
  version: number;
  updatedAt: number;
  data: Record<string, unknown>;
  deleted?: boolean;                       // tombstone flag for optimistic deletes
}

let dbPromise: Promise<IDBPDatabase> | null = null;

const getDB = () => {
  dbPromise ??= openDB('sync-store', 1, {
    upgrade(db) {
      db.createObjectStore('records', { keyPath: 'id' });
      db.createObjectStore('outbox', { keyPath: 'id' });
    },
  });
  return dbPromise;
};

/** Optimistic write: update the local record AND queue an intent, atomically. */
export async function writeLocal(
  entityId: string,
  payload: Record<string, unknown>,
  baseVersion: number
): Promise<void> {
  const db = await getDB();
  const tx = db.transaction(['records', 'outbox'], 'readwrite');

  const existing = await tx.objectStore('records').get(entityId);
  await tx.objectStore('records').put({
    id: entityId,
    version: baseVersion,          // stays here until a server ack advances it
    updatedAt: Date.now(),
    data: { ...(existing?.data ?? {}), ...payload },
  } satisfies RecordRow);

  await tx.objectStore('outbox').put({
    id: crypto.randomUUID(),       // server dedupes retried sends on this
    op: 'update',
    entityId,
    baseVersion,
    payload,
    createdAt: Date.now(),
  } satisfies OutboxEntry);

  await tx.done;
}

/** Optimistic delete: tombstone locally so version tracking survives, queue the intent. */
export async function deleteLocal(entityId: string, baseVersion: number): Promise<void> {
  const db = await getDB();
  const tx = db.transaction(['records', 'outbox'], 'readwrite');
  const existing = await tx.objectStore('records').get(entityId);
  if (existing) {
    await tx.objectStore('records').put({ ...existing, deleted: true, updatedAt: Date.now() });
  }
  await tx.objectStore('outbox').put({
    id: crypto.randomUUID(),
    op: 'delete',
    entityId,
    baseVersion,
    payload: null,
    createdAt: Date.now(),
  } satisfies OutboxEntry);
  await tx.done;
}

type SendResult = { status: 200; serverRecord: RecordRow } | { status: 409; serverRecord: RecordRow };

/** Drain the outbox in creation order; stop on the first conflict rather than compounding it. */
export async function drainOutbox(
  send: (entry: OutboxEntry) => Promise<SendResult>
): Promise<void> {
  const db = await getDB();
  const entries = (await db.getAll('outbox') as OutboxEntry[]).sort(
    (a, b) => a.createdAt - b.createdAt
  );

  for (const entry of entries) {
    const result = await send(entry);

    if (result.status === 200) {
      await db.delete('outbox', entry.id);
      continue;
    }

    // 409: the server's version has advanced past baseVersion — a concurrent edit landed.
    // Hand off to conflict resolution and stop draining this entity's remaining ops so a
    // later op (which assumes the same stale base) doesn't overwrite the resolved result.
    await onConflict(entry, result.serverRecord);
    break;
  }
}

// Implemented in the advanced variant below.
declare function onConflict(entry: OutboxEntry, serverRecord: RecordRow): Promise<void>;

The send callback is left abstract here on purpose — in production it wraps fetch against PATCH /records/:id or DELETE /records/:id, attaches the entry’s id as an Idempotency-Key header, and maps the HTTP response into the SendResult shape. Wiring drainOutbox to actual browser events is what turns it from a standalone function into a running sync loop, and it needs a re-entrancy guard so a flapping connection doesn’t start a second drain while the first is still in flight:

let draining = false;

async function triggerDrain(): Promise<void> {
  if (draining || !navigator.onLine) return;
  draining = true;
  try {
    await drainOutbox(sendToServer);
  } finally {
    draining = false;
  }
}

window.addEventListener('online', () => void triggerDrain());
// Also attempt a drain on service worker activation, so a tab that was
// backgrounded through the disconnect doesn't sit on a stale queue.
navigator.serviceWorker?.ready.then(() => void triggerDrain());

Architecture note #

This is a local-first architecture: the UI reads and writes the records store, never waiting on the network, and treats the server as an eventually-consistent replica rather than the source of truth for the current interaction. That only stays safe if versioning is monotonic — every write to a record on the server increments version (or bumps updatedAt past any value a client could have observed), and the server never accepts a write whose baseVersion doesn’t match its current stored version.

baseVersion has to travel with the op, not just live on the local record, because the outbox entry is what crosses the network boundary independently of the record’s current local state. By the time an entry drains, the local record may have been edited again (bumping the local view of version optimistically), but the outbox entry still carries the version the original edit was based on — which is exactly what the server needs to compare against. Stripping it out and re-deriving “current local version” at drain time would silently launder a stale edit into looking current.

Prefer a server-incremented integer counter over a client-supplied timestamp for version. Wall-clock time is not monotonic across devices with clock skew, and two writes issued in the same millisecond on different machines are indistinguishable by timestamp alone. An integer that only the server ever increments removes that ambiguity: the comparison baseVersion === currentVersion is exact, not a fuzzy “close enough” heuristic that occasionally lets a stale write through.


Edge cases and gotchas #

Offline sync reconcile flow A flowchart with five stages: a local optimistic write, an outbox entry in IndexedDB, a drain routine triggered on reconnect, a 200 acknowledgement path that commits and dequeues, and a 409 conflict path that routes into a resolve function which can re-enqueue a merged operation. Local write optimistic update to records store Outbox entry op, entityId, baseVersion, payload (IndexedDB) idempotency key = id on 'online' Drain routine sends entries in creation order 200 ack Commit dequeue outbox entry advance local version 409 conflict resolve() auto-merge or manual-merge UI re-enqueue merged op drain path conflict / re-enqueue path
Failure mode Symptom Mitigation
Partial drain (network drops mid-batch) Some entries dequeued, others still queued with now-stale baseVersion Drain sequentially per entity and stop that entity’s chain on first failure; resume from where the outbox left off, never from a recomputed “current” state
Reordering (two tabs write the same entity) A newer edit sent before an older queued one, corrupting causal order Sort strictly by createdAt within an entity; never parallelize sends for the same entityId
Duplicate delivery on retry A request that actually succeeded is retried after a timeout, double-applying it Use the outbox entry’s id as an idempotency key the server stores and rejects on replay
Deletes racing with concurrent edits A delete for a record someone just edited destroys their new data Tombstone locally, queue the delete as an intent with baseVersion, and let the server’s conflict check apply the same 409 logic as updates
Outbox unbounded growth while offline for a long stretch IndexedDB usage climbs, drain takes longer, quota pressure increases Coalesce multiple queued updates to the same entityId into one entry before the first send attempt, keeping the latest payload but the earliest baseVersion

These rows aren’t independent failure modes so much as one hazard viewed from different angles. Reordering (row two) is what turns a partial drain (row one) from a recoverable pause into permanent corruption — an out-of-order resend can push a stale baseVersion after a newer one already landed. Idempotency keys (row three) are what make it safe to resume a partial drain in the first place, since a retried send that actually succeeded the first time must be a harmless no-op rather than a second write. Treat the three together: sequential per-entity draining plus stable idempotency keys is the minimum bar, not an either/or choice.


Advanced variant: conflict resolution beyond last-write-wins #

Last-write-wins is simple but destructive by definition — it is the exact anomaly this guide opened with, just moved to whichever side happens to sync second. A better default classifies the conflict: if the two sides touched disjoint fields, merge them; only fall back to a manual-merge UI (or a full version-vector comparison across multiple replicas) when the same field diverged on both sides.

interface ConflictResult {
  strategy: 'auto-merged' | 'manual-required' | 'server-wins';
  merged?: Record<string, unknown>;
}

/**
 * Classify a conflict: merge non-overlapping field changes automatically,
 * defer to the user when the same field changed on both sides.
 */
export function resolve(
  local: OutboxEntry,
  serverRecord: RecordRow,
  baseSnapshot: Record<string, unknown> // fields as they existed at local.baseVersion
): ConflictResult {
  if (local.op === 'delete') {
    // Deleting a record the server has since edited — keep the server's newer data
    return { strategy: 'server-wins' };
  }

  const localChangedKeys = Object.keys(local.payload ?? {});
  const serverChangedKeys = Object.keys(serverRecord.data).filter(
    (key) => serverRecord.data[key] !== baseSnapshot[key]
  );
  const overlap = localChangedKeys.filter((key) => serverChangedKeys.includes(key));

  if (overlap.length === 0) {
    return {
      strategy: 'auto-merged',
      merged: { ...serverRecord.data, ...local.payload },
    };
  }

  // Both sides touched the same field(s) with different values — no safe automatic answer
  return { strategy: 'manual-required' };
}

When resolve returns auto-merged, re-enqueue a fresh outbox entry carrying the merged payload and the server’s current version as the new baseVersion, so the next drain attempt is built on an up-to-date base rather than repeating the same conflict. When it returns manual-required, surface both versions side by side and let the user pick or hand-merge fields — silently discarding either side at that point is the same anomaly with extra steps. Version vectors extend this same idea across more than two replicas: instead of a single baseVersion integer, each record carries a map of replica-id to counter, and a conflict is detected when neither vector dominates the other rather than by a single number mismatching.

Version vectors earn their extra complexity once a record can have more than two independent writers — three devices syncing the same shared list, say, each of which might be offline at different times. A single integer version cannot express “device A and device C both advanced independently while device B stayed put”; a vector keyed by replica id can, because each replica only ever increments its own slot. Detecting a conflict then becomes a comparison of whether one vector’s entries are all greater-than-or-equal-to the other’s — a “happens-after” relationship — rather than comparing a lone number. If neither vector dominates the other, the two edits are concurrent by definition, and the same field-overlap check inside resolve decides whether that concurrency is safe to auto-merge.


Testing and CI/CD validation #

Simulate the offline-to-online transition and a conflicting server response deterministically — waiting for real network flakiness in CI is not reproducible.

import { describe, it, expect, vi } from 'vitest';
import { writeLocal, drainOutbox } from './outbox';

describe('offline sync outbox', () => {
  it('drains queued writes once reconnected and reports no lost updates', async () => {
    // Force navigator.onLine to reflect an offline-then-online transition
    Object.defineProperty(navigator, 'onLine', { value: false, configurable: true });

    await writeLocal('record-42', { title: 'Local edit' }, 3);

    Object.defineProperty(navigator, 'onLine', { value: true, configurable: true });
    window.dispatchEvent(new Event('online'));

    const send = vi.fn().mockResolvedValue({
      status: 200,
      serverRecord: { id: 'record-42', version: 4, updatedAt: Date.now(), data: { title: 'Local edit' } },
    });

    await drainOutbox(send);

    expect(send).toHaveBeenCalledTimes(1);
    expect(send.mock.calls[0][0].baseVersion).toBe(3);
  });

  it('detects a conflict and stops draining that entity rather than compounding it', async () => {
    await writeLocal('record-42', { title: 'Stale local edit' }, 3);

    const conflictingServerRecord = {
      id: 'record-42',
      version: 5, // advanced past baseVersion=3 — a concurrent edit landed
      updatedAt: Date.now(),
      data: { title: 'Someone else’s edit', tags: ['urgent'] },
    };
    const send = vi.fn().mockResolvedValue({ status: 409, serverRecord: conflictingServerRecord });

    await drainOutbox(send);

    expect(send).toHaveBeenCalledTimes(1); // stopped after the conflict, did not push further
  });
});

Run these alongside end-to-end coverage that toggles a service worker’s simulated offline mode (DevTools’ network throttling profile, or Playwright’s context.setOffline(true)), performs an edit, flips back online, and asserts the outbox object store is empty and the record’s final version matches the server’s — never a value lower than what a concurrent writer produced.

Wire this suite into the same CI stage that runs before deploy, gated on any change to outbox.ts, resolve.ts, or the server’s conflict-detection endpoint. A passing suite here is the cheapest signal available that a schema change to version or updatedAt hasn’t silently broken optimistic concurrency for every offline client that already has queued writes sitting in an outbox. For longer-running confidence beyond unit coverage, point the drain routine at a fault-injection proxy that returns 409 for a configurable percentage of requests across a randomized set of entity ids, then assert that the final server state for every entity matches either the last write that was genuinely acknowledged or a correctly auto-merged result — never a payload from a request that received a conflict response.

For a deeper look at how to weigh last-write-wins against a version-vector approach for records with many concurrent writers, see Resolving Offline Sync Conflicts: Last-Write-Wins vs Version Vectors, which works through the trade-offs of each strategy under higher write concurrency than the single-conflict case covered here.


Frequently asked questions #

What causes the lost-update anomaly in offline-first PWAs? A user edits a record while offline; meanwhile another device or user edits the same record on the server. When the offline client reconnects and pushes its local copy without checking the server’s current version, it silently overwrites the other change instead of merging it.

Why must baseVersion travel with every outbox operation? baseVersion records which server version the client’s edit was based on. Without it, the server cannot tell an update building on the current state from one building on stale state, so it has no basis for rejecting a conflicting write with a 409.

How does the outbox pattern handle deleted records? Deletes are written as tombstones rather than removed immediately: the local record is flagged deleted and a delete intent is queued. This preserves baseVersion for conflict checking and lets the UI hide the record without losing the ability to detect a concurrent edit on the server.

Should the outbox drain in parallel or strictly in order? Strictly in order per entity. Draining out of order lets a later op with a stale baseVersion overwrite the result of an earlier one that already advanced the server’s version, reintroducing the exact anomaly the outbox exists to prevent.

How do you test conflict resolution without a live backend? Stub navigator.onLine and fire synthetic online and offline events, then point the sync routine at a mock server that returns 200 for the first N requests and 409 for a chosen entity. Assert the outbox empties, the conflict handler is invoked exactly once, and no field from either side is dropped.