Optimistic UI Updates & Rollback

An offline-first app cannot make the user wait for a server round-trip before showing the result of an action. The answer is optimistic UI: apply the mutation to local state and IndexedDB immediately, render the new value, queue the network operation, and reconcile — or roll back — once the server responds. Done well, the interface feels instant and survives a flaky connection or a closed tab. Done carelessly, a rejected write leaves the durable store divergent from the server, dependent edits stack on top of a phantom record, and the user sees data that never persisted. This guide is part of Offline Sync Strategies & Background Workflows and establishes the full pattern: snapshot the previous state, apply an optimistic patch with a recorded inverse, persist the queued operation, then confirm on success and revert on permanent failure.

Optimistic update lifecycle from apply to confirm or rollback A state machine showing an optimistic write that applies locally and enqueues a network operation, then either confirms on success or rolls back its recorded inverse on permanent failure. Snapshot + apply record inverse patch Enqueue op persisted in IndexedDB Send to server with retry/backoff Confirm drop inverse, dequeue Roll back apply inverse patch Notify UI reverted + reason Tab closes mid-flight? Queue survives and resumes on next load

The pattern in one sentence

For every mutation, capture the data needed to undo it before you change anything, apply the change to both the UI and the durable store, persist the network operation in a queue that outlives the page, and keep that operation in the queue until the server confirms it — only then discard the undo information. The five moving parts are the snapshot (a before-image or inverse operation), the optimistic patch, the persisted queue, the reconciliation step that runs on a successful response, and the rollback step that runs on a permanent failure. Everything else — ordering, idempotency, retry — is detail layered onto this spine.

This pattern leans on IndexedDB as the durable store because Web Storage is synchronous, size-limited, and unstructured. The transactional guarantees that make rollback safe come from IndexedDB Transaction Management: applying the optimistic patch and recording its inverse must happen in a single readwrite transaction so the two can never drift apart after a crash.

Operation and queue model

Model each mutation as a self-describing operation record. The record holds enough to (a) replay the network call, (b) undo the local effect, and © order itself relative to other pending operations.

Field Type Purpose
id string Client-generated UUID; also the idempotency key sent to the server
entity string Object store the mutation targets (e.g. "notes")
key IDBValidKey Primary key of the affected record
kind "create" | "update" | "delete" Drives how the inverse is built
inverse InversePatch Before-image or tombstone needed to undo the local change
payload unknown Body sent to the server
seq number Monotonic sequence number for ordering and dependency tracking
status "pending" | "inflight" | "failed" Lifecycle state
attempts number Retry counter feeding the backoff delay

The inverse is the heart of rollback. For an update, it is the previous full record (a before-image). For a create, it is a tombstone instructing rollback to delete the key. For a delete, it is the deleted record so rollback can re-insert it. Recording this at apply time is non-negotiable: the durable store has already been mutated, and without the inverse there is nothing to revert to.

Implementation walkthrough

Open the database and define stores

We keep two object stores: the domain store (notes) and the operation queue (ops). Co-locating them in one database lets a single transaction span both, which is what makes the apply-and-enqueue step atomic.

const DB_NAME = 'optimistic-db';
const DB_VERSION = 1;

function openDb(): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(DB_NAME, DB_VERSION);
    req.onupgradeneeded = () => {
      const db = req.result;
      if (!db.objectStoreNames.contains('notes')) {
        db.createObjectStore('notes', { keyPath: 'id' });
      }
      if (!db.objectStoreNames.contains('ops')) {
        const ops = db.createObjectStore('ops', { keyPath: 'id' });
        ops.createIndex('by_seq', 'seq');
        ops.createIndex('by_status', 'status');
      }
    };
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

applyOptimistic — mutate, record the inverse, enqueue in one transaction

applyOptimistic is the single entry point for every user mutation. It reads the current record to build the inverse, writes the new value, and persists the operation — all inside one readwrite transaction across both stores. If any step throws, the transaction aborts and nothing is committed, so the store can never be left half-mutated.

type Note = { id: string; title: string; body: string; rev: number };
type Inverse =
  | { type: 'restore'; record: Note }   // undo update/delete
  | { type: 'remove'; key: string };    // undo create

interface Op {
  id: string;
  entity: 'notes';
  key: string;
  kind: 'create' | 'update' | 'delete';
  inverse: Inverse;
  payload: unknown;
  seq: number;
  status: 'pending' | 'inflight' | 'failed';
  attempts: number;
}

let seqCounter = Date.now();
const nextSeq = () => ++seqCounter;

function tx<T>(
  db: IDBDatabase,
  stores: string[],
  mode: IDBTransactionMode,
  run: (t: IDBTransaction) => Promise<T> | T,
): Promise<T> {
  return new Promise((resolve, reject) => {
    const t = db.transaction(stores, mode);
    let result: T;
    Promise.resolve(run(t)).then((r) => { result = r; }).catch(reject);
    t.oncomplete = () => resolve(result);
    t.onerror = () => reject(t.error);
    t.onabort = () => reject(t.error ?? new Error('transaction aborted'));
  });
}

function reqAsync<T>(r: IDBRequest<T>): Promise<T> {
  return new Promise((resolve, reject) => {
    r.onsuccess = () => resolve(r.result);
    r.onerror = () => reject(r.error);
  });
}

async function applyOptimistic(
  db: IDBDatabase,
  kind: Op['kind'],
  key: string,
  next: Note | null,
  payload: unknown,
): Promise<Op> {
  return tx(db, ['notes', 'ops'], 'readwrite', async (t) => {
    const notes = t.objectStore('notes');
    const ops = t.objectStore('ops');
    const prev = (await reqAsync(notes.get(key))) as Note | undefined;

    // Build the inverse BEFORE mutating the store.
    let inverse: Inverse;
    if (kind === 'create') {
      inverse = { type: 'remove', key };
      notes.add(next!);
    } else if (kind === 'update') {
      if (!prev) throw new Error(`cannot update missing ${key}`);
      inverse = { type: 'restore', record: prev };
      notes.put(next!);
    } else {
      if (!prev) throw new Error(`cannot delete missing ${key}`);
      inverse = { type: 'restore', record: prev };
      notes.delete(key);
    }

    const op: Op = {
      id: crypto.randomUUID(),
      entity: 'notes',
      key,
      kind,
      inverse,
      payload,
      seq: nextSeq(),
      status: 'pending',
      attempts: 0,
    };
    ops.add(op);
    return op;
  });
}

The UI updates from the same write you just committed — render next immediately rather than re-reading. Because the inverse was captured from prev inside the transaction, a crash at any point either leaves the old state (transaction aborted) or the new state plus a recoverable operation (transaction committed). There is no in-between.

Reconcile — flush the queue when the server responds

A flush loop drains pending operations in seq order, marks each in-flight, sends it, and on a 2xx confirms by deleting both the operation and its now-unneeded inverse. On failure it branches on whether the error is transient or permanent.

async function loadPending(db: IDBDatabase): Promise<Op[]> {
  return tx(db, ['ops'], 'readonly', async (t) => {
    const idx = t.objectStore('ops').index('by_seq');
    const all = (await reqAsync(idx.getAll())) as Op[];
    return all.filter((o) => o.status !== 'inflight').sort((a, b) => a.seq - b.seq);
  });
}

async function setStatus(db: IDBDatabase, id: string, status: Op['status']): Promise<void> {
  await tx(db, ['ops'], 'readwrite', async (t) => {
    const store = t.objectStore('ops');
    const op = (await reqAsync(store.get(id))) as Op | undefined;
    if (op) { op.status = status; op.attempts += status === 'failed' ? 1 : 0; store.put(op); }
  });
}

async function confirm(db: IDBDatabase, id: string): Promise<void> {
  await tx(db, ['ops'], 'readwrite', (t) => { t.objectStore('ops').delete(id); });
}

async function flush(db: IDBDatabase): Promise<void> {
  const pending = await loadPending(db);
  for (const op of pending) {
    await setStatus(db, op.id, 'inflight');
    try {
      const res = await fetch(`/api/${op.entity}/${op.key}`, {
        method: op.kind === 'delete' ? 'DELETE' : op.kind === 'create' ? 'POST' : 'PUT',
        headers: { 'content-type': 'application/json', 'idempotency-key': op.id },
        body: op.kind === 'delete' ? undefined : JSON.stringify(op.payload),
      });
      if (res.ok) {
        await confirm(db, op.id);
      } else if (res.status >= 500 || res.status === 429) {
        await setStatus(db, op.id, 'pending');   // transient: leave queued for retry
        break;                                    // stop the run; back off before retrying
      } else {
        await rollback(db, op);                   // permanent (4xx): revert local state
      }
    } catch {
      await setStatus(db, op.id, 'pending');      // network error: transient, retry later
      break;
    }
  }
}

Rollback — apply the inverse and surface it

rollback re-opens a readwrite transaction, applies the recorded inverse, removes the failed operation, then emits a UI event so the interface can revert and explain. The full rollback mechanics, including cascade revert of dependent operations, are covered in Rolling Back Failed Optimistic Updates in IndexedDB.

const rollbackEvents = new EventTarget();

async function rollback(db: IDBDatabase, op: Op): Promise<void> {
  await tx(db, ['notes', 'ops'], 'readwrite', (t) => {
    const notes = t.objectStore('notes');
    if (op.inverse.type === 'remove') notes.delete(op.inverse.key);
    else notes.put(op.inverse.record);
    t.objectStore('ops').delete(op.id);
  });
  rollbackEvents.dispatchEvent(
    new CustomEvent('rolledback', { detail: { key: op.key, kind: op.kind } }),
  );
}

Concurrency and lifecycle

Ordering of dependent operations. Operations carry a monotonic seq and are flushed in that order. A create followed by an update of the same record must reach the server in sequence, so the flush loop processes the queue serially rather than firing requests in parallel. If you do parallelize, partition by key so operations on the same record stay ordered while unrelated records flush concurrently.

Idempotency. Each operation’s id doubles as an idempotency key in the request header. If the tab closes after the server commits but before the client records the confirmation, the next flush resends the same operation; an idempotent server recognizes the key and returns the original result instead of duplicating the write. Without this, a retry after an ambiguous failure creates phantom records.

Tab closes mid-flight. Because the queue lives in IndexedDB, a closed or crashed tab loses nothing. On the next load, call flush during startup (and ideally from a visibilitychange handler) to resume pending operations. Any operation left in inflight from a previous session is treated as pending by loadPending, so an interrupted send is retried under its idempotency key rather than abandoned.

Cross-tab coordination. Two tabs flushing the same queue will race. Guard the flush with a named lock so only one tab drains the queue at a time — the Web Locks API for Cross-Tab Coordination is the right primitive, and the related Preventing Duplicate Sync with Web Locks walks through the exact pattern.

Error handling and retry

The flush loop divides failures into two classes. A transient failure — a network error, an HTTP 429, or a 5xx — means the server might accept the operation later, so the operation stays queued and the run backs off. A permanent failure — most 4xx responses, especially 422 validation errors — means retrying will never succeed, so the operation rolls back immediately.

For transient failures, schedule the next flush with growing delays rather than hammering the server. Use the same backoff machinery described in Handling Network Flakiness with Exponential Backoff:

function backoffDelay(attempts: number): number {
  const base = Math.min(30_000, 500 * 2 ** attempts); // cap at 30s
  return base / 2 + Math.random() * (base / 2);        // full jitter
}

let timer: ReturnType<typeof setTimeout> | null = null;

async function scheduleFlush(db: IDBDatabase): Promise<void> {
  if (timer) return;
  const pending = await loadPending(db);
  const worst = pending.reduce((m, o) => Math.max(m, o.attempts), 0);
  timer = setTimeout(async () => {
    timer = null;
    await flush(db);
    const remaining = await loadPending(db);
    if (remaining.length) await scheduleFlush(db); // keep retrying while work remains
  }, backoffDelay(worst));
}

A conflict (HTTP 409, or a 412 precondition failure) is a special case: the server has a newer value than the optimistic write assumed. This is not a plain rollback — reverting blindly would discard the user’s intent. Hand it to your merge logic in Conflict Resolution Algorithms to reconcile the two versions before deciding whether to keep, merge, or discard the local change.

Surfacing rollback to the user. A silent revert is worse than the original failure — the user watches their edit vanish with no explanation. Listen for the rolledback event and show a non-blocking toast that names what reverted and why, ideally with a retry affordance:

rollbackEvents.addEventListener('rolledback', (e) => {
  const { key, kind } = (e as CustomEvent).detail;
  showToast(`Couldn't save your ${kind} — the change was undone.`, {
    action: { label: 'Try again', onClick: () => retryFromUi(key) },
  });
});

declare function showToast(msg: string, opts?: unknown): void;
declare function retryFromUi(key: string): void;

Browser compatibility

The pattern depends only on IndexedDB, crypto.randomUUID, fetch, and EventTarget — all broadly available. The one operational caveat is storage eviction on iOS Safari.

Browser IndexedDB crypto.randomUUID Persisted queue notes
Chrome / Edge 92+ Yes Yes Stable; request navigator.storage.persist() to resist eviction
Firefox 95+ Yes Yes Stable; persistence prompt on some profiles
Safari 15.4+ (macOS) Yes Yes Works; subject to overall origin quota
iOS Safari 16 / 17 Yes Yes 7-day inactivity eviction can wipe the queue (see below)

On iOS Safari 16 and 17, Intelligent Tracking Prevention can clear IndexedDB after roughly seven days of inactivity for sites that are not installed to the Home Screen. A persisted operation queue is exactly the kind of data this wipes, so a user who creates work offline and does not return for a week may lose unsynced operations. Mitigate by calling navigator.storage.persist() early, flushing aggressively when the app regains focus, and treating the queue as best-effort rather than guaranteed durable. The mechanics are detailed in The iOS Safari 7-Day Storage Eviction Workaround.

Performance and scale

Batch the flush, not the apply. applyOptimistic must be synchronous-feeling, so keep its transaction tiny — read one record, write one record, append one operation. Amortize cost on the network side instead: when many operations are pending, send them as a single batched request where your API supports it, so a burst of edits is one round-trip rather than dozens.

Avoid UI thrash. Render directly from the value you wrote in applyOptimistic rather than re-querying IndexedDB after every mutation. Re-reading the store on each keystroke forces a transaction per render and stutters the interface. Keep a hot in-memory view, treat IndexedDB as the durable mirror, and reconcile the two only when the server confirms or a rollback fires.

Index the queue. The by_status and by_seq indexes let the flush loop pull just the pending operations in order without scanning the whole store, which matters once a long offline session accumulates hundreds of operations. Indexing strategy is covered in Indexing Strategies for Fast Queries.

Cap retained operations. Confirmed operations are deleted, but failed-and-rolled-back ones and their UI notifications should not accumulate. Prune the queue on a successful full drain so it never grows unbounded across long-lived sessions.

For the broader background-delivery side of this — registering a sync that fires when connectivity returns even if no tab is open — pair this pattern with Background Sync API Implementation.

Frequently Asked Questions

Why store the queue in IndexedDB instead of memory?

Because optimistic operations must survive a tab close, reload, or crash. An in-memory queue is lost the moment the page goes away, so any edit made offline and not yet synced would silently disappear. IndexedDB persists the queue durably, and its transactions let you apply the optimistic patch and enqueue the operation atomically. See IndexedDB Transaction Management.

What is the difference between a transient and a permanent failure?

A transient failure (network drop, HTTP 429, or 5xx) might succeed if retried, so the operation stays queued and is retried with exponential backoff. A permanent failure (most 4xx, especially 422 validation) will never succeed, so the operation rolls back immediately and the user is told. A 409/412 conflict is neither — it routes to Conflict Resolution Algorithms.

How do I prevent duplicate writes when a retry happens after an ambiguous failure?

Send the operation’s client-generated id as an idempotency key on every request. If the server already committed the write before the client lost the response, it recognizes the key on retry and returns the original result instead of creating a duplicate. This makes the resend-on-reload behavior safe.

Related