Preventing Duplicate Sync with Web Locks

You ship an offline-first app, the user opens it in three tabs, the network drops and recovers, and your server logs three identical POST /api/orders for the same queued item. The duplicate records are real, the user is confused, and the bug only reproduces with more than one tab open. This is a coordination failure, and the fix is the Web Locks API for Cross-Tab Coordination. For the broader queue-and-retry mechanics this page builds on, see Browser Storage Fundamentals & Quotas. Below is the complete, runnable fix plus a leader-election variant, verification steps, and fallbacks for the rare browser that lacks locks.

Three tabs flushing without and with a lock The top row shows three tabs each posting the same item, producing three duplicate server writes; the bottom row shows the same three tabs gated by an ifAvailable lock so only one posts. Without a lock Tab A flush Tab B flush Tab C flush 3 duplicate POSTs With ifAvailable lock Tab A holds B, C skip 1 POST Server Idempotency-Key dedupes the rest

Root cause

Each tab is an independent execution context. When you register an online listener or a setInterval flush timer, every open tab registers its own copy. The browser fires the online event in all of them at once, so all of them read the same outbox from localStorage or IndexedDB — which is shared storage — and all of them start POSTing the same rows. Nothing in the Web Storage or IndexedDB specifications serializes this; a read followed by a write is not atomic across tabs, so even an “is this already syncing?” flag in shared storage races: two tabs read the flag as unset in the same tick before either sets it.

The Web Locks specification gives the platform a real same-origin mutex. A lock acquired with navigator.locks.request(name, callback) is held for exactly as long as the callback’s returned promise stays pending, is visible to every same-origin tab and worker, and releases automatically when the holder finishes or its tab closes. That single guarantee is what we need: elect one flusher, let the rest stand down.

The fix, step by step

The approach has three layers. The lock makes only one tab flush. ifAvailable: true makes the other tabs skip instead of queuing (a queued second flush is pointless once the first drains the outbox). And an idempotency key on each request is the belt-and-suspenders that protects against retries and the rare browser without locks.

Step 1 — Wrap the flush in a lock

// Each queued item carries a stable idempotency key generated once when it
// was enqueued, so a retry of the SAME item is safe even if a POST repeats.
interface OutboxItem {
  id: string;            // local primary key
  key: string;           // idempotency key, stable across retries
  url: string;
  body: unknown;
}

declare function readOutbox(): Promise<OutboxItem[]>;
declare function deleteFromOutbox(id: string): Promise<void>;

async function flushOutbox(): Promise<void> {
  const items = await readOutbox();
  for (const item of items) {
    const res = await fetch(item.url, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Idempotency-Key': item.key, // server dedupes if this ever repeats
      },
      body: JSON.stringify(item.body),
    });
    if (res.ok) {
      await deleteFromOutbox(item.id);
    } else if (res.status >= 400 && res.status < 500) {
      // Permanent client error: drop so we do not loop forever.
      await deleteFromOutbox(item.id);
    }
    // 5xx / network errors: leave it in the outbox for the next flush.
  }
}

// Only one tab across the origin runs flushOutbox at a time; if a tab is
// already flushing, every other tab's callback gets lock === null and skips.
async function syncNow(): Promise<'flushed' | 'skipped' | 'unsupported'> {
  if (!('locks' in navigator)) {
    await flushOutbox(); // see fallback section
    return 'unsupported';
  }
  return navigator.locks.request(
    'sync-flush',
    { mode: 'exclusive', ifAvailable: true },
    async (lock) => {
      if (lock === null) return 'skipped'; // another tab owns the flush
      await flushOutbox();
      return 'flushed';
    },
  );
}

Step 2 — Wire it to your triggers

// Every tab registers these, but only one will actually flush thanks to the
// lock; the others resolve to 'skipped' harmlessly.
function startSyncCoordination(): void {
  window.addEventListener('online', () => { void syncNow(); });
  setInterval(() => { void syncNow(); }, 30_000);
  void syncNow(); // attempt once on load
}

startSyncCoordination();

With this in place, three tabs firing online simultaneously produce exactly one flushOutbox run. The first tab to be granted sync-flush does the work; the other two get lock === null and return 'skipped' without touching the network.

Step 3 (variant) — Leader election with a long-lived lock

If you want one tab to own all shared work for its lifetime — a single polling loop, a single WebSocket — hold the lock indefinitely instead of per-flush. The holder keeps the lock by never resolving its callback promise until it shuts down; every other tab’s request stays pending and is granted only when the leader’s tab closes.

// The elected leader holds 'sync-leader' for the lifetime of its tab. When
// that tab closes, the lock releases and a waiting tab is promoted instantly.
function electLeader(onBecomeLeader: () => void): void {
  if (!('locks' in navigator)) { onBecomeLeader(); return; }
  void navigator.locks.request('sync-leader', () =>
    new Promise<void>(() => {
      // This promise never resolves, so the lock is held until the tab dies.
      onBecomeLeader();
    }),
  );
}

electLeader(() => {
  console.info('This tab is the sync leader');
  setInterval(() => { void flushOutbox(); }, 15_000);
});

Use the ifAvailable per-flush variant when any tab may safely do the work occasionally; use leader election when exactly one tab must own a continuous resource.

Verification

Confirm the fix without a server change:

  1. Open the app in two browser tabs side by side and open DevTools in both.
  2. Queue an item while offline, then toggle the network back online so both tabs fire their online handler at the same moment.
  3. Watch the console: exactly one tab logs the flush and the other logs 'skipped'. In the Network panel, only one tab issues the POST.
  4. Inspect live lock state from the console in either tab:
// Run in the DevTools console to see who holds and who waits.
const snapshot = await navigator.locks.query();
console.table(snapshot.held);     // the flushing tab appears here
console.table(snapshot.pending);  // empty for ifAvailable (callers skip, never queue)

For the leader-election variant, snapshot.held shows one sync-leader entry across all tabs; close the leader tab and re-run query() in another tab to watch a waiter get promoted.

Edge cases and fallback

Browsers without Web Locks. Every current major browser supports navigator.locks (Safari since 16.4), but for an older build the syncNow guard above degrades to an unguarded flushOutbox. The safety net is the Idempotency-Key header: when the server treats a repeated key as a no-op returning the original result, duplicate POSTs from uncoordinated tabs never create duplicate records. If you cannot change the server, elect a flusher with BroadcastChannel instead — have each tab broadcast a claim with a random token, wait a tick, and let the lowest token win — though this is racier than a lock and is why server-side idempotency remains the most robust backstop.

A tab that hangs while holding the lock. Because the lock releases when the tab closes or crashes, a truly dead tab never blocks others. The remaining risk is a tab that is alive but stuck mid-flush. With ifAvailable other tabs simply skip and retry on their next interval, so progress resumes once the stuck tab recovers or is closed. If you need a hard bound, replace ifAvailable with an AbortSignal.timeout(...) on a waiting request so a caller gives up after a deadline rather than waiting on a wedged holder.

Partial flushes. If the leader is closed mid-loop, un-deleted items remain in the outbox and the next elected tab re-POSTs them — which is exactly why each item carries a stable idempotency key, so the server collapses the repeat into the original write.

Frequently Asked Questions

Why use ifAvailable instead of letting tabs queue for the lock?

Once the lock holder flushes the shared outbox, there is nothing left for a queued tab to do, so queuing just wastes a wakeup to discover an empty queue. ifAvailable: true makes other tabs skip immediately when the lock is taken, which is both faster and the correct semantics for a deduplicated background task.

Do I still need idempotency keys if I have a lock?

Yes, as defense in depth. The lock prevents concurrent flushes, but a retried request after a flaky 5xx, or an uncoordinated tab on a browser without locks, can still repeat a POST. A stable idempotency key lets the server collapse any repeat into the original write, so the two mechanisms together make duplicates impossible. See Web Locks API for Cross-Tab Coordination.

What happens if the leader tab closes mid-sync?

The lock releases automatically when the tab closes, and a waiting tab is granted it almost immediately. Any items the closed tab had not yet deleted from the outbox are picked up by the new holder; the idempotency key ensures the re-POST of an already-written item is a no-op on the server.

Related