This guide builds on the durability contract in Session State Persistence & Hydration Fallbacks, applying it to the case where losing state is most expensive: a user who has already spent minutes typing.

The problem: the longer the form, the worse the failure #

A four-step onboarding wizard represents ten minutes of a user’s attention. When the tab crashes, an extension kills the renderer, or a deploy triggers a reload, the default web behaviour discards everything. The user does not blame the browser. They abandon the flow — and the further through it they were, the more likely they are never to start again.

The failure surface is broader than “the tab crashed”, too:

  • Accidental navigation. A mis-tapped back gesture on mobile unmounts the whole flow.
  • Session expiry mid-flow. A redirect to login on step three returns the user to step one, empty.
  • A crash inside one step. An error boundary catches a date-picker failure and re-mounts the subtree — with fresh, empty state, because the boundary reset also reset the form.
  • A second tab. The user opens the flow again in another tab and now two snapshots compete for the same key.

The pattern below treats the flow as data rather than as DOM: an explicit machine, snapshotted transactionally, restored only with consent.

Prerequisites #


Core implementation: a snapshot-friendly flow machine #

Persisting a form is easy; persisting which step the user was on, with which validation state is the part that decides whether recovery feels correct. Model it explicitly.

// wizard-state.ts
export const SNAPSHOT_SCHEMA = 4;   // bump when any field name or shape changes

export type StepId = 'account' | 'address' | 'shipping' | 'review';

export interface WizardState {
  schema: number;
  flowId: string;                 // one per started flow, survives reloads
  current: StepId;
  /** Only steps the user has actually visited are persisted. */
  values: Partial<Record<StepId, Record<string, unknown>>>;
  /** Steps that passed validation, so restore does not re-prompt for them. */
  completed: StepId[];
  updatedAt: number;
}

/** Fields that must never reach storage, matched per step. */
const NEVER_PERSIST: Partial<Record<StepId, string[]>> = {
  account: ['password', 'passwordConfirm', 'otp'],
  review: ['cardNumber', 'cvc'],
};

export function sanitise(step: StepId, values: Record<string, unknown>) {
  const banned = new Set(NEVER_PERSIST[step] ?? []);
  return Object.fromEntries(Object.entries(values).filter(([k]) => !banned.has(k)));
}

export function nextState(prev: WizardState, step: StepId, values: Record<string, unknown>, valid: boolean): WizardState {
  return {
    ...prev,
    current: step,
    values: { ...prev.values, [step]: sanitise(step, values) },
    completed: valid
      ? Array.from(new Set([...prev.completed, step]))
      : prev.completed.filter((s) => s !== step),
    updatedAt: Date.now(),
  };
}

The persistence layer writes the whole state in one transaction. Partial writes are the enemy: a snapshot with step three’s values but step two’s completion flags produces a wizard that refuses to advance and gives the user no way to see why.

// wizard-store.ts
const DB = 'wizard_recovery_v1';
const STORE = 'flows';

function open(): Promise<IDBDatabase> {
  return new Promise((resolve, reject) => {
    const req = indexedDB.open(DB, 1);
    req.onupgradeneeded = () => {
      const db = req.result;
      if (!db.objectStoreNames.contains(STORE)) db.createObjectStore(STORE, { keyPath: 'flowKey' });
    };
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });
}

export async function saveSnapshot(flowKey: string, state: WizardState): Promise<void> {
  const db = await open();
  await new Promise<void>((resolve, reject) => {
    const tx = db.transaction(STORE, 'readwrite');
    tx.objectStore(STORE).put({ flowKey, state });
    tx.oncomplete = () => resolve();          // durable only here
    tx.onerror = () => reject(tx.error);
    tx.onabort = () => reject(new Error('snapshot aborted'));
  });
}

export async function loadSnapshot(flowKey: string): Promise<WizardState | null> {
  const db = await open();
  const record = await new Promise<{ state: WizardState } | undefined>((resolve, reject) => {
    const tx = db.transaction(STORE, 'readonly');
    const req = tx.objectStore(STORE).get(flowKey);
    req.onsuccess = () => resolve(req.result);
    req.onerror = () => reject(req.error);
  });

  const state = record?.state;
  if (!state) return null;
  // A snapshot from an older field layout is worse than no snapshot.
  if (state.schema !== SNAPSHOT_SCHEMA) {
    void clearSnapshot(flowKey);
    return null;
  }
  // Expire stale drafts so a three-week-old address does not resurface.
  if (Date.now() - state.updatedAt > 14 * 24 * 60 * 60 * 1000) {
    void clearSnapshot(flowKey);
    return null;
  }
  return state;
}

export async function clearSnapshot(flowKey: string): Promise<void> {
  const db = await open();
  const tx = db.transaction(STORE, 'readwrite');
  tx.objectStore(STORE).delete(flowKey);
}
Snapshot timeline across a four-step wizard and a crash A horizontal timeline marks four steps. Small ticks during each step represent debounced writes while typing; a taller tick at each boundary represents the durable write on step transition. A crash marker appears inside step three, and an arrow below returns to the start of step three, with steps one and two labelled restored as complete. 1 · account 2 · address 3 · shipping 4 · review debounced writes while typing durable write at each transition tab crash restore lands on step 3 with steps 1–2 marked complete unsaved keystrokes since the last debounce are the only loss

Architecture note: recovery is a resume, not a replay #

The instinct to record every keystroke as an event log and replay it is almost always wrong for forms. Replay reproduces intermediate invalid states, re-triggers async validators, and can re-fire side effects such as address lookups. What the user wants is far simpler: to be back where they were, with what they had typed.

That means the snapshot is a value, written whole, read whole. It also means the debounce window is a deliberate, documented data-loss budget: at 600 ms you are promising that a crash costs at most the last few characters. Publishing that number internally stops the recurring argument about whether recovery is “working” when someone loses half a word.

One consequence worth designing for: a boundary reset must not wipe the snapshot. When a crash inside step three re-mounts the step subtree, the recovery layer should re-seed it from IndexedDB rather than from empty defaults — the coordination point described in State Reset & Cleanup Protocols.

Silent restoration is a bug report waiting to happen. Prompt, and make the prompt specific.

// RestorePrompt.tsx
export function RestorePrompt({ snapshot, onResume, onDiscard }: Props) {
  const minutes = Math.max(1, Math.round((Date.now() - snapshot.updatedAt) / 60000));
  const stepLabel = STEP_LABELS[snapshot.current];

  return (
    <section role="alertdialog" aria-labelledby="resume-title" aria-describedby="resume-desc">
      <h2 id="resume-title">Resume where you left off?</h2>
      <p id="resume-desc">
        We saved your progress {minutes} minute{minutes === 1 ? '' : 's'} ago,
        on <strong>{stepLabel}</strong>. Payment details are never saved and will
        need to be entered again.
      </p>
      <button type="button" onClick={onResume}>Resume {stepLabel}</button>
      <button type="button" onClick={onDiscard}>Start over</button>
    </section>
  );
}

Three details in that markup do real work. role="alertdialog" puts screen-reader users in the same decision as everyone else, matching the practice in Building Accessible Error Fallback UI with ARIA Live Regions. Naming the step tells the user what they are agreeing to. Stating what was not saved pre-empts the “it lost my card details” ticket, which is otherwise the most common complaint about a feature that is working exactly as designed.

Restore decision What to keep What to clear
User resumes Values, completed steps, current step Nothing
User declines Nothing Snapshot record and any tab-local hint flag
Schema version mismatch Nothing Snapshot record, silently, before any prompt
Snapshot older than the expiry window Nothing Snapshot record, silently
Flow submitted successfully Nothing Snapshot record, in the same transaction as marking submitted

The last row is the one teams forget. A snapshot that survives a successful submission will offer to restore a completed order the next time the user starts a new one.

Files, uploads and other non-serialisable values #

A File cannot be written back into an <input type="file"> — browsers forbid it, and no amount of persistence changes that. What you can do is persist the bytes and re-attach them to your own upload pipeline.

// attachments.ts
interface StoredAttachment {
  id: string;
  name: string;
  type: string;
  size: number;
  blob: Blob;          // structured-clone friendly; no base64 bloat
  uploadedKey?: string; // set once the server has it — then drop the blob
}

const MAX_PERSISTED_BYTES = 8 * 1024 * 1024;

export async function persistAttachment(flowKey: string, file: File): Promise<StoredAttachment | null> {
  if (file.size > MAX_PERSISTED_BYTES) return null;  // too big to be worth it
  const record: StoredAttachment = {
    id: crypto.randomUUID(),
    name: file.name,
    type: file.type,
    size: file.size,
    blob: file.slice(),   // a Blob, stored natively by structured clone
  };
  await putAttachment(flowKey, record);
  return record;
}

/** Once the server holds the file, keep only the pointer. */
export async function markUploaded(flowKey: string, id: string, uploadedKey: string) {
  const rec = await getAttachment(flowKey, id);
  if (!rec) return;
  await putAttachment(flowKey, { ...rec, uploadedKey, blob: new Blob([]) });
}

After a reload the file input renders empty while your UI shows “invoice.pdf — recovered, ready to send”. That asymmetry has to be designed deliberately, or users will re-pick the same file and you will upload it twice. The quota interaction here is real: an 8 MB blob competes with everything else the origin stores, which is why the cap exists and why Handling QuotaExceededError When Persisting Session State is required reading before you raise it.

What a snapshot restores, partially restores, and never stores Three stacked bands. The top band, fully restored, lists text values, selections and current step. The middle band, restored outside the input, lists file attachments re-attached to the upload pipeline while the file input itself stays empty. The bottom band, never persisted, lists passwords, one-time codes and card details. fully restored text values · selections · current step · completed steps · flow id restored outside the input file bytes re-attached to the uploader; the file input itself stays empty never persisted passwords · one-time codes · card numbers and CVC · government identifiers

Validating recovery before it ships #

Form recovery fails quietly, so it needs a test that fails loudly. The cheapest end-to-end assertion drives the flow to step three, kills the page context rather than navigating away, reopens it, and checks both the prompt and the restored values:

// e2e/wizard-recovery.spec.ts
test('a crash on step 3 resumes with steps 1-2 intact', async ({ browser }) => {
  const ctx = await browser.newContext();
  const page = await ctx.newPage();

  await page.goto('/signup');
  await fillAccountStep(page, { email: '[email protected]' });
  await fillAddressStep(page, { city: 'Manchester' });
  await page.getByLabel('Delivery notes').fill('Leave with neighbour');
  await page.waitForTimeout(900);           // let the debounce flush

  await page.close({ runBeforeUnload: false });  // simulate a hard crash

  const resumed = await ctx.newPage();
  await resumed.goto('/signup');
  await expect(resumed.getByRole('alertdialog')).toContainText('shipping');
  await resumed.getByRole('button', { name: /resume/i }).click();
  await expect(resumed.getByLabel('Delivery notes')).toHaveValue('Leave with neighbour');
  await expect(resumed.getByTestId('step-2-status')).toHaveText('Complete');
});

Closing the page with runBeforeUnload: false is the detail that makes this a crash test rather than a navigation test: no unload handler runs, so anything the implementation was saving “on the way out” is correctly lost. Pair it with a second case that declines the prompt and asserts the snapshot is gone, which is the assertion that catches a discard path that only clears in-memory state. The wider harness for this style of deliberate failure is covered in Testing & Fault Injection for Error Boundaries.

Choosing the debounce interval is choosing a data-loss budget A chart with debounce interval along the horizontal axis. One line shows worst-case characters lost rising with the interval; another shows writes per minute falling. A marker at six hundred milliseconds is labelled as the usual balance point. debounce interval → 100 ms 600 ms 3 s characters at risk writes per minute documented budget

Deep dives in this guide #

Frequently asked questions #

Should I restore a form automatically or ask the user first?

Ask, unless the form is trivially short. Silent restoration is indistinguishable from a bug when the user deliberately abandoned the flow, and it is dangerous for anything with financial consequence. One prompt naming what will be restored and how old it is converts a surprise into a choice.

Can I persist a file the user selected in a file input?

You cannot restore a File into the input element, but you can persist its bytes as a Blob in IndexedDB and re-attach them to your upload logic after a reload. The input will look empty, so the UI must show the recovered file as a separate confirmed attachment.

How often should a long form snapshot itself?

Debounce keystrokes to roughly 400–800 ms, and always snapshot on blur and on step transition. Every keystroke wastes transactions; only on step change loses everything typed into the step the user crashed on.

What should happen if the snapshot schema changed since it was written?

Discard it, or migrate it explicitly. Restoring values written against an older field layout produces a form that looks filled in but fails validation in ways the user cannot diagnose.

Is sessionStorage enough for wizard recovery?

Only for reloads within the same tab. It is cleared when the tab closes, so it survives a reload but not a browser crash or a device restart — the exact events recovery exists for. Use IndexedDB for the payload.