Rolling Back Failed Optimistic Updates in IndexedDB

You applied an optimistic write — the new value is in IndexedDB and the UI already shows it — but the server rejected it with a 422 validation error. The durable store now holds data the backend will never accept, the user sees a record that does not really exist, and any edits made on top of it are stacked on a phantom. This page is part of Optimistic UI Updates & Rollback and solves exactly this: reverting a committed optimistic write cleanly, atomically, and including any dependent operations that were built on top of it.

Before-image capture and revert on permanent failure A sequence showing the record value over time: original, optimistic value with a captured before-image, then a 422 rejection that restores the before-image. Original record rev 4 in store Optimistic value rev 5 + before-image saved Server returns 422 permanent failure Restored record before-image (rev 4)

Root cause

Optimistic writes mutate the durable store before the server confirms them. That is the whole point — the UI must reflect the change instantly — but it means the moment a write is committed to IndexedDB, the only record of the prior value is whatever you chose to keep. If you kept nothing, there is no path back: IndexedDB has no undo log you can replay, and put/delete overwrite the previous value irreversibly. The store is now durably divergent from the server with no recipe to reconcile it.

The fix is to record an inverse at apply time and store it in the same transaction as the optimistic write. For an update, the inverse is the full previous record (a before-image). For a create, it is a tombstone that says “delete this key.” For a delete, it is the removed record so it can be re-inserted. Because the optimistic write and its inverse are committed together, a crash can never leave you with a mutated store but no way to undo it.

Step-by-step fix

1. Capture the before-image at apply time, in one transaction

The critical move is atomicity: read the old record, write the new one, and persist the inverse without any other transaction interleaving. A single readwrite transaction over both the domain store and the operation store guarantees this. If the transaction aborts, neither the optimistic value nor the inverse is committed.

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

interface Op {
  id: string;
  key: string;
  kind: 'create' | 'update' | 'delete';
  inverse: Inverse;
  seq: number;
  dependsOn: string | null; // id of an earlier op this one was built on
}

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

function txDone(t: IDBTransaction): Promise<void> {
  return new Promise((resolve, reject) => {
    t.oncomplete = () => resolve();
    t.onerror = () => reject(t.error);
    t.onabort = () => reject(t.error ?? new Error('aborted'));
  });
}

let seq = 0;

async function applyWithInverse(
  db: IDBDatabase,
  kind: Op['kind'],
  key: string,
  next: Note | null,
  dependsOn: string | null,
): Promise<Op> {
  const t = db.transaction(['notes', 'ops'], 'readwrite');
  const notes = t.objectStore('notes');
  const ops = t.objectStore('ops');

  const prev = (await reqAsync(notes.get(key))) as Note | undefined;

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

  const op: Op = { id: crypto.randomUUID(), key, kind, inverse, seq: ++seq, dependsOn };
  ops.add(op);
  await txDone(t);
  return op;
}

2. Apply the inverse on permanent failure, atomically

When the server returns a permanent error (a 4xx such as 422), revert. Open a fresh readwrite transaction, apply the inverse, delete the failed operation, and emit a UI event so the interface can roll back its view and tell the user. Restoring the before-image and deleting the operation share one transaction so they cannot drift apart.

const bus = new EventTarget();

async function revert(db: IDBDatabase, op: Op): Promise<void> {
  const t = db.transaction(['notes', 'ops'], 'readwrite');
  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);
  await txDone(t);
  bus.dispatchEvent(new CustomEvent('reverted', { detail: { key: op.key } }));
}

3. Cascade-revert dependent operations

If a newer optimistic operation was built on top of the failed one — an update applied to a record that a still-unconfirmed create produced — reverting only the failed operation leaves an orphan. Walk the queue for operations that transitively depend on the failed id and revert them in reverse seq order (newest first) so each inverse lands on the state its operation actually produced.

async function loadOps(db: IDBDatabase): Promise<Op[]> {
  const t = db.transaction(['ops'], 'readonly');
  const all = (await reqAsync(t.objectStore('ops').getAll())) as Op[];
  await txDone(t);
  return all;
}

async function cascadeRevert(db: IDBDatabase, failedId: string): Promise<void> {
  const ops = await loadOps(db);
  const byId = new Map(ops.map((o) => [o.id, o]));

  // Collect the failed op plus everything that transitively depends on it.
  const doomed = new Set<string>([failedId]);
  let grew = true;
  while (grew) {
    grew = false;
    for (const o of ops) {
      if (o.dependsOn && doomed.has(o.dependsOn) && !doomed.has(o.id)) {
        doomed.add(o.id);
        grew = true;
      }
    }
  }

  // Revert newest-first so each inverse undoes the right layer.
  const ordered = [...doomed]
    .map((id) => byId.get(id)!)
    .sort((a, b) => b.seq - a.seq);

  for (const op of ordered) await revert(db, op);
}

Verification

Simulate a 422 and assert the store returns to the before-image. The test drives the real IndexedDB transactions, so it proves atomicity end to end.

async function verifyRollback(db: IDBDatabase): Promise<void> {
  // Seed an original record (rev 4).
  await applyWithInverse(db, 'create', 'n1', { id: 'n1', title: 'A', body: '', rev: 4 }, null);
  // Drop the create's op so we are testing a clean update-then-revert.
  const t0 = db.transaction(['ops'], 'readwrite');
  t0.objectStore('ops').clear();
  await txDone(t0);

  // Optimistic update to rev 5.
  const op = await applyWithInverse(
    db, 'update', 'n1', { id: 'n1', title: 'A edited', body: 'x', rev: 5 }, null,
  );

  // Server rejects with 422 -> revert.
  await revert(db, op);

  // Assert the store is back to the before-image (rev 4, title 'A').
  const t1 = db.transaction(['notes'], 'readonly');
  const after = (await reqAsync(t1.objectStore('notes').get('n1'))) as Note;
  await txDone(t1);
  if (after.rev !== 4 || after.title !== 'A') {
    throw new Error(`rollback failed: ${JSON.stringify(after)}`);
  }
  console.info('rollback verified: store restored to before-image');
}

In DevTools you can confirm the same thing visually: open Application -> IndexedDB, watch the notes record flip to rev 5 on apply, then return to rev 4 after revert, while the matching entry disappears from the ops store.

Edge cases and a fallback

Conflict, not failure. If the server rejects with a 409 or 412 because it holds a newer value than your optimistic write assumed, a blind revert discards the user’s intent and may reintroduce stale data. Treat this as a merge problem, not a rollback: hand both versions to Conflict Resolution Algorithms and let the merge decide whether to keep, combine, or drop the local change. Reserve the before-image revert for genuine permanent failures like validation errors.

Partial batch failure. When operations are sent as a batch and only some are rejected, revert only the failed ones plus their dependents — never the whole batch. Keep each operation’s inverse independent so a per-operation revert is always possible, and process rejections individually through cascadeRevert.

Transaction atomicity is the fallback’s foundation. Every revert runs inside a single readwrite transaction, so if applying the inverse fails partway, IndexedDB aborts and the store is left untouched rather than half-reverted. The next flush retries the revert from a known-consistent state. The transaction semantics this relies on are detailed in IndexedDB Transaction Management.

Frequently Asked Questions

Why not just re-fetch the record from the server instead of keeping a before-image?

Re-fetching fails the moment you are offline, which is precisely when optimistic updates matter most. It also costs a round-trip per rollback and races against other pending writes. A locally captured before-image makes rollback instant, offline-capable, and deterministic.

What happens to operations that depend on a reverted create?

They must be reverted too, newest-first, or they will reference a record that no longer exists. Track each operation’s dependsOn id and run a cascade revert that collects every transitive dependent before applying inverses in reverse sequence order.

Is a 409 conflict the same as a permanent failure?

No. A 409 or 412 means the server has a newer value, so reverting blindly would throw away the user’s intent. Route conflicts to Conflict Resolution Algorithms and reserve before-image reverts for validation-style permanent failures.

Related