# Offline data sync and conflict resolution

> Queue edits in IndexedDB, retry them with Background Sync or an online fallback, and resolve stale writes with HTTP entity tags.

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

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

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.

:::caution
The `online` event reports a change in the browser's network state; it does not prove that the
application server is reachable. MDN recommends using the value as a hint rather than disabling
features from it. Keep failed operations in IndexedDB until the server accepts them.
:::

## Examples

The page and its service worker share one IndexedDB outbox.

### 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`.

```js
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

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.

```js
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()`:

```js
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.

:::caution
Do not treat `'SyncManager' in window` as proof that a sync was registered. The example awaits
`registration.sync.register()` and takes the page retry path if that promise rejects.
Background Sync has [limited availability](https://developer.mozilla.org/en-US/docs/Web/API/Background_Synchronization_API).
That fallback runs only while the page remains open, so a queued edit can remain pending after the
page closes.
:::

## 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)

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

- [Offline strategies](/guides/offline/)
- [Background Sync API browser support](/compatibility/background-sync/)
- [Storage reference](/reference/storage/)
- [Indexed Database API 3.0](https://www.w3.org/TR/IndexedDB/) (W3C)
- [Background Synchronization API](https://developer.mozilla.org/en-US/docs/Web/API/Background_Synchronization_API) (MDN)
- [HTTP Semantics: Preconditions](https://www.rfc-editor.org/rfc/rfc9110.html#name-preconditions) (RFC Editor)
- [The Idempotency-Key HTTP Header Field](https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-idempotency-key-header-07) (IETF Internet-Draft, work in progress)