Offline data sync and conflict resolution
Published Updated
IndexedDB can hold pending edits until connectivity returns. Submit them when a request can reach the server, and keep an edit queued if the server reports that its base version is stale. Use Background Sync where available, with retries while the page is open as a fallback.
Browser and platform support
Section titled “Browser and platform support”IndexedDB provides object stores and transactions for browser-side records. Background Sync
is limited availability according to MDN and depends on a service worker in a secure context,
so an offline write path must not depend on it alone. The example detects SyncManager and
uses the window’s online event plus an immediate retry when Background Sync is absent.
navigator.onLine is only a hint. MDN notes that browsers and operating systems use different
heuristics and that a local network can be available without Internet access. Let the request
result decide whether an edit leaves the queue.
Define the sync contract
Section titled “Define the sync contract”Store an operation ID, the resource URL, the edited document, and the entity tag received with
the base document. Send that tag in If-Match and the operation ID as the endpoint’s
Idempotency-Key. This is an application API contract, not behavior that HTTP supplies by itself.
The example adopts the semantics in the expired IETF Internet-Draft as work in progress: a key is
unique to one request payload, a retry after the original completes receives the stored result,
and a concurrent retry receives a conflict response. RFC 9110 says an origin server must not
perform the requested method when If-Match is false. The server may respond with 412 Precondition Failed, or with a successful status when it determines that the requested
state-changing operation has already succeeded.
The Internet-Draft requires the resource to publish its idempotency specification, including an expiration policy when applicable, and to manage each key’s lifecycle. Set the retention window longer than the client’s automatic retry window, and stop automatic retries before it expires; after expiry, read the resource to determine whether the operation completed before creating a new operation and key. A longer window uses storage for more keys, fingerprints, and results, while a shorter window limits how long a retry can be deduplicated. The Internet-Draft requires the resource to manage the key lifecycle and publish an expiration policy, but it does not prescribe a duration.
In this example, a successful response removes the queued operation. A 412 response keeps it
with a conflict status so the UI can load the server version and ask the user to merge or
replace. A network error also keeps the operation for a later attempt.
Examples
Section titled “Examples”The page and its service worker share one IndexedDB outbox.
Queue and flush an edit
Section titled “Queue and flush an edit”The storage module creates one object store and exposes queueEdit() and flushOutbox(). The
server endpoint must return an ETag when the document is read, evaluate If-Match when it is
updated, and deduplicate updates by Idempotency-Key.
const database = new Promise((resolve, reject) => { const request = indexedDB.open('notes', 1); request.onupgradeneeded = () => { request.result.createObjectStore('outbox', { keyPath: 'id' }); }; request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error);});
const LEASE_MS = 60_000;const RETRY_DELAY_MS = 5_000;
function transactionDone(transaction) { return new Promise((resolve, reject) => { transaction.oncomplete = () => resolve(); transaction.onabort = transaction.onerror = () => reject(transaction.error); });}
async function claimNextOperation(owner) { const db = await database; const transaction = db.transaction('outbox', 'readwrite'); const store = transaction.objectStore('outbox'); const request = store.openCursor(); let claimed;
await new Promise((resolve, reject) => { request.onsuccess = () => { const cursor = request.result; if (!cursor) return resolve();
const operation = cursor.value; const now = Date.now(); if ( operation.status === 'conflict' || operation.leaseUntil > now || operation.retryAfter > now ) { cursor.continue(); return; }
claimed = { ...operation, leaseOwner: owner, leaseUntil: now + LEASE_MS, }; const update = cursor.update(claimed); update.onsuccess = () => resolve(); update.onerror = () => reject(update.error); }; request.onerror = () => reject(request.error); }); await transactionDone(transaction); return claimed;}
async function finishOperation(id, owner, outcome) { const db = await database; const transaction = db.transaction('outbox', 'readwrite'); const store = transaction.objectStore('outbox'); const request = store.get(id); request.onsuccess = () => { const current = request.result; if (!current || current.leaseOwner !== owner) return;
if (outcome === 'accepted') { store.delete(id); return; }
store.put({ ...current, status: outcome === 'conflict' ? 'conflict' : current.status, leaseOwner: null, leaseUntil: 0, retryAfter: outcome === 'retry' ? Date.now() + RETRY_DELAY_MS : 0, }); }; await transactionDone(transaction);}
async function getNextAttemptAt() { const db = await database; const transaction = db.transaction('outbox', 'readonly'); const request = transaction.objectStore('outbox').openCursor(); let earliest = Infinity;
await new Promise((resolve, reject) => { request.onsuccess = () => { const cursor = request.result; if (!cursor) return resolve(); const operation = cursor.value; const now = Date.now(); const readyAt = Math.max(operation.retryAfter ?? 0, operation.leaseUntil ?? 0); if (operation.status !== 'conflict') { earliest = Math.min(earliest, Math.max(now, readyAt)); } cursor.continue(); }; request.onerror = () => reject(request.error); }); await transactionDone(transaction); return Number.isFinite(earliest) ? earliest : null;}
export class OutboxRetryError extends Error { constructor(retryAt) { super('The outbox still has operations awaiting another attempt.'); this.name = 'OutboxRetryError'; this.retryAt = retryAt; }}
export async function flushOutbox() { const owner = crypto.randomUUID(); let operation; while ((operation = await claimNextOperation(owner))) { let response; try { response = await fetch(operation.url, { method: 'PUT', headers: { 'content-type': 'application/json', 'if-match': operation.etag, // The draft defines Idempotency-Key as a Structured Field String. 'idempotency-key': JSON.stringify(operation.id), }, body: JSON.stringify(operation.document), }); } catch { await finishOperation(operation.id, owner, 'retry'); continue; }
if (response.status === 412) { await finishOperation(operation.id, owner, 'conflict'); continue; } await finishOperation(operation.id, owner, response.ok ? 'accepted' : 'retry'); }
const retryAt = await getNextAttemptAt(); if (retryAt !== null) { throw new OutboxRetryError(retryAt); }}
export async function queueEdit(operation) { const db = await database; const transaction = db.transaction('outbox', 'readwrite'); const { leaseOwner, leaseUntil, retryAfter, ...queued } = operation; transaction.objectStore('outbox').put({ ...queued, status: operation.status ?? 'pending', }); await transactionDone(transaction);}Call queueEdit({ id, url, document, etag }) after a local edit. Use one stable, unique id for
each logical operation and reuse it only when retrying that operation. The transaction that claims
an operation also writes a time-limited lease, so a page and its worker cannot normally submit the
same operation at once. Within the endpoint’s published retention window, its idempotency contract
covers the remaining case where a request outlives its lease.
Detect Background Sync and fall back
Section titled “Detect Background Sync and fall back”Register a one-off sync when the API is present. Otherwise, keep the online listener until a
flush completes without a retryable operation, and use the earliest retryAt value to schedule
another attempt while the page remains open.
import { OutboxRetryError, flushOutbox, queueEdit } from './outbox.js';
let retryTimer;let pageFlush;let flushAgain = false;
function stopPageRetry() { clearTimeout(retryTimer); retryTimer = undefined; window.removeEventListener('online', onOnline);}
function schedulePageRetry(retryAt) { clearTimeout(retryTimer); retryTimer = setTimeout(onOnline, Math.max(0, retryAt - Date.now()));}
async function flushFromPage() { if (pageFlush) { flushAgain = true; return pageFlush; } pageFlush = (async () => { do { flushAgain = false; try { await flushOutbox(); if (!flushAgain) stopPageRetry(); } catch (error) { if (!(error instanceof OutboxRetryError)) throw error; schedulePageRetry(error.retryAt); } } while (flushAgain); })().finally(() => { pageFlush = undefined; }); return pageFlush;}
function onOnline() { void flushFromPage().catch((error) => console.error('Outbox flush failed', error));}
async function requestFlush() { if ('serviceWorker' in navigator && 'SyncManager' in window) { try { const registration = await navigator.serviceWorker.ready; await registration.sync.register('flush-note-outbox'); return; } catch { // Continue to the page retry path. } } window.addEventListener('online', onOnline); await flushFromPage();}
if ('indexedDB' in window) { await queueEdit({ id: crypto.randomUUID(), url: '/api/notes/42', document: { title: 'Packing list' }, etag: '"version-from-the-last-read"', }); await requestFlush();} else { showManualRetry('This browser cannot keep the edit in an offline queue.');}The service worker uses the same storage module and extends the event lifetime with
waitUntil():
self.addEventListener('sync', (event) => { if (event.tag === 'flush-note-outbox') { event.waitUntil(flushOutbox()); }});The worker and the page use the same durable queue. IndexedDB serializes their claim
transactions, and completion mutates a record only while the caller still owns its lease. A
failed request releases its lease before flushOutbox() rejects. That rejection lets
waitUntil() report a failed sync, while the page path waits until retryAt without a tight loop.
Conflict handling
Section titled “Conflict handling”When the application finds an operation whose status is conflict, fetch the current server
representation and show both versions. The client can then queue a merged document with the
new entity tag and a pending status, or discard the local operation. Do not delete the queued
edit merely because the device reports that it is online.
The IndexedDB specification makes a read/write transaction atomic for its scoped object stores.
That makes the lease claim exclusive across page and worker connections. The lease expires so a
crashed context cannot block the queue forever; the endpoint’s idempotency contract handles a
repeated request while its key is retained. After the published key expiry, verify server state
instead of assuming that repetition remains safe. The If-Match precondition separately protects
the server resource from a stale write.
Correction (2026-10-20)
Section titled “Correction (2026-10-20)”This revision reflects RFC 9110’s optional response choices for a false If-Match condition and
limits the idempotency-retention statement to the Internet-Draft’s lifecycle and expiration-policy
requirements.
See also
Section titled “See also”- Offline strategies
- Background Sync API browser support
- Storage reference
- Indexed Database API 3.0 (W3C)
- Background Synchronization API (MDN)
- HTTP Semantics: Preconditions (RFC Editor)
- The Idempotency-Key HTTP Header Field (IETF Internet-Draft, work in progress)