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);
}
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.
The restore prompt: consent, age, and scope #
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.
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.
Deep dives in this guide #
- Restoring a Multi-Step Checkout After a Tab Crash walks the full resume path, including what to do about a cart that changed price while the user was away.
- Persisting File Uploads Across a Reload with IndexedDB covers blob storage, resumable upload keys, and the empty-input problem in detail.
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.
Related #
- Session State Persistence & Hydration Fallbacks — the durability model this flow inherits
- Draft Auto-Save & Recovery Workflows — the same discipline applied to free-form editors
- LocalStorage & IndexedDB Sync Strategies — choosing the storage tier and surviving quota limits
- Cross-Tab State Synchronization — keeping two open copies of the same flow from clobbering each other