The exact problem: a reader gets three steps into a wizard, the tab reloads — a crash, a stray refresh, a phone reclaiming memory — and the form comes back empty, or worse, comes back on step four with step two’s answers gone.

Context and Prerequisites

This page assumes the wizard is already modelled as a machine rather than as paginated markup, as described in multi-step form state machines. The machine’s most useful property here is that its state is derivable: step status is a function of the answers, so persistence only has to save the answers. If your wizard stores step status directly, save the answers anyway and recompute — persisted derived state is the source of almost every “resumed on the wrong step” report.

Core Pattern: Persist Answers, Recompute Status

import type { WizardState, StepValues } from './types';

/** Bump when the shape of what we persist changes incompatibly. */
const DRAFT_VERSION = 2;

interface PersistedDraft {
  readonly version: number;
  readonly formId: string;        // which form, so two wizards cannot collide
  readonly recordId: string | null;  // which record — editing order 12 is not order 13
  readonly savedAt: number;       // for expiry and for conflict messages
  readonly values: Record<string, StepValues>;
  readonly currentId: string;     // a hint, re-validated on load — never trusted
}

const keyFor = (formId: string, recordId: string | null) =>
  `draft:${formId}:${recordId ?? 'new'}`;

/**
 * Save. Deliberately stores ONLY the answers plus a navigation hint: step status,
 * validity and the computed path are all recomputed on load, so a change to the
 * path function cannot resurrect a draft that no longer makes sense.
 */
export function saveDraft(state: WizardState, formId: string, recordId: string | null): void {
  const draft: PersistedDraft = {
    version: DRAFT_VERSION,
    formId,
    recordId,
    savedAt: Date.now(),
    values: state.values,
    currentId: state.currentId,
  };
  try {
    localStorage.setItem(keyFor(formId, recordId), JSON.stringify(draft));
  } catch {
    // Quota exceeded, or storage disabled in this context. A wizard that cannot
    // save a draft must still work — never let this throw into the reducer.
  }
}

/**
 * Load. Returns null for anything we cannot safely resume: wrong form, wrong
 * record, old shape, or expired. Each of those is a normal outcome, not an error.
 */
export function loadDraft(
  formId: string,
  recordId: string | null,
  maxAgeMs = 7 * 24 * 60 * 60 * 1000,
): PersistedDraft | null {
  let raw: string | null = null;
  try {
    raw = localStorage.getItem(keyFor(formId, recordId));
  } catch {
    return null;
  }
  if (!raw) return null;

  let draft: PersistedDraft;
  try {
    draft = JSON.parse(raw) as PersistedDraft;
  } catch {
    // Corrupt entry — a partial write, or something else using the same key.
    try { localStorage.removeItem(keyFor(formId, recordId)); } catch { /* ignore */ }
    return null;
  }

  if (draft.version !== DRAFT_VERSION) return null;      // no silent migration
  if (draft.formId !== formId) return null;
  if (draft.recordId !== recordId) return null;
  if (Date.now() - draft.savedAt > maxAgeMs) return null;
  return draft;
}

/**
 * Rebuild the machine from saved answers. Status is recomputed by replaying the
 * validators, so the reader lands on the first step that is genuinely incomplete
 * rather than wherever they happened to be when the tab died.
 */
export function resume(
  draft: PersistedDraft,
  deps: { path: (v: Record<string, StepValues>) => readonly string[];
          validate: (id: string, v: StepValues) => boolean },
): WizardState {
  const path = deps.path(draft.values);
  const status: Record<string, StepStatus> = {};
  let firstIncomplete: string | null = null;

  for (const id of path) {
    const values = draft.values[id];
    // A step with no saved answers, or answers that no longer validate, is not
    // complete — regardless of what the reader had reached before the reload.
    const complete = values !== undefined && deps.validate(id, values);
    if (complete) {
      status[id] = { phase: 'complete', values: Object.freeze({ ...values }) };
    } else {
      status[id] = firstIncomplete === null ? { phase: 'available' } : { phase: 'locked' };
      if (firstIncomplete === null) firstIncomplete = id;
    }
  }

  // Honour the saved position only if it is a step the reader may actually be on.
  const hintUsable = status[draft.currentId] &&
    status[draft.currentId].phase !== 'locked';
  return {
    stepIds: path,
    currentId: hintUsable ? draft.currentId : (firstIncomplete ?? path[path.length - 1]),
    status,
    values: draft.values,
    submitAttempted: false,
  };
}
What happens between a reload and the reader seeing their answers First, read the stored entry and reject it outright if the persisted version, the form id, the record id or the age do not match what this page expects — each rejection is a normal outcome that falls back to an empty form. Second, recompute the path from the saved answers, so conditional steps reflect what the reader actually chose rather than what the path looked like when they saved. Third, replay each step's validator over its saved answers to rebuild status from scratch. Fourth, place the reader on the saved step id only if that step is not locked by the recomputed status, and otherwise on the first genuinely incomplete step. Four stages, and nothing derived is trusted 1 · read version, form, record, age must all match else: empty form 2 · recompute path from the saved answers, not from the saved path branches stay correct 3 · replay validators status rebuilt from the answers, step by step rules may have changed 4 · place the reader saved step, if legal; else first incomplete never a locked step Why stage 3 exists at all A draft can outlive the rules that produced it. A field that was optional last week may be required today, and a step that was complete then is not complete now. Replaying the validators is what makes that a corrected resume rather than a form that submits values it would reject today. The saved step id is a hint, not state: it can only ever move the reader forward to somewhere they were already allowed.

Step-by-Step Walkthrough

  1. Key by form and record. draft:checkout:new and draft:checkout:order-9182 are different drafts. Sharing one key is how editing a second record shows the first record’s answers.

  2. Version the payload. When the persisted shape changes, bump DRAFT_VERSION and let old drafts be discarded. Writing a migration for a seven-day-old draft is work that will be wrong more often than it is right.

  3. Save the answers only. Status, validity, progress and the path are all derived. Persisting them means persisting a snapshot of rules that may since have changed.

  4. Save on a debounce, not on every keystroke. localStorage writes are synchronous and block the main thread; at one write per keystroke on a large draft that is measurable. A 500 ms debounce plus a flush on step change and on visibilitychange catches everything that matters.

  5. Recompute on load, then place the reader. Replay validators to rebuild status, then use the saved step id only if the recomputed status says that step is reachable.

  6. Clear on success. A draft that survives its own submission is how a reader who starts a second order sees the first one’s answers.

When to write, and when to delete Debounced while typing, at around five hundred milliseconds: missing this loses everything typed since the last step change. Flush on step change: missing it is mostly covered by the debounce, but a fast reader can advance inside the debounce window. Flush on visibilitychange: this is the one that catches a phone reclaiming the tab, and it is the only reliable signal on mobile, since unload does not fire dependably there. Delete after a confirmed submission: missing it means the next reader of the same form sees the previous submission's answers offered back to them. Moment Action Missing it costs while typing write, debounced 500ms everything since the last step step change flush immediately a fast reader beats the debounce visibilitychange flush immediately the whole draft, on mobile confirmed submit delete the entry the next reader sees these answers Use visibilitychange rather than unload or beforeunload: on mobile those fire unreliably, and this one is the signal that matters. Delete only after the server has confirmed — deleting on submit loses the draft when the request fails.

Failure Modes and Edge Cases

1. Storage is unavailable or full

Private browsing modes, storage partitioning, and a quota already consumed by something else all make setItem throw. A wizard whose save path can throw is a wizard that breaks on the reader’s next keystroke. Wrap every access, treat failure as “no draft”, and consider surfacing it once — “we could not save your progress in this browser” is honest and lets the reader decide to finish in one sitting.

2. The draft outlives the rules

A field that was optional when the draft was written may be required today. Replaying the validators on load turns that into a corrected resume — the step comes back available rather than complete, and the reader is asked once. Trusting a persisted complete flag instead means submitting values the current rules would reject.

3. Two tabs, one key

Both tabs read the same key, both write it, and the last write wins silently. Listen for storage events to detect the other tab, and either take the newer draft or tell the reader — see resolving conflicts when restoring a draft for the reconciliation itself.

4. Sensitive values in the draft

localStorage is readable by any script on the origin and survives until deleted. Card numbers, passwords, one-time codes and government identifiers must be excluded from the persisted payload — allow-list the fields you save rather than blocking the ones you do not, so a new field is excluded by default.

// Allow-list, not deny-list: a field added next month is not persisted until
// somebody deliberately adds it here.
const PERSISTABLE: Readonly<Record<string, readonly string[]>> = {
  contact: ['email', 'phone'],
  delivery: ['line1', 'line2', 'city', 'postcode', 'method'],
  payment: [],                     // nothing from this step is ever written
};

5. The key collides with another feature

Two features writing draft on the same origin is not hypothetical — analytics libraries, feature-flag clients and design-system playgrounds all write to localStorage. Namespacing the key by form and record makes a collision unlikely; validating the parsed payload’s formId makes it harmless.

The key namespaces the draft; the payload proves it The key is composed of a fixed prefix, the form identifier and the record identifier, so a new checkout and an edit of an existing order occupy different entries and cannot overwrite one another. The payload repeats both identifiers, which means a key collision with another feature is detected on load rather than trusted: a parsed object whose form id does not match is discarded exactly like a corrupt one. Two worked keys are shown, one for a new checkout and one for editing order nine one eight two. the key draft formId — "checkout" recordId — or "new" draft:checkout:new — a first-time order draft:checkout:order-9182 — editing an existing one The payload repeats both identifiers on purpose A parsed draft whose formId does not match is discarded exactly like a corrupt one — a collision becomes harmless. Never key by URL: query parameters and tracking fragments change the key without changing the form.

6. Restoring silently

A form that quietly fills itself in is disorienting, and for a reader using a screen reader it is invisible. Announce the restore in a live region and offer a way out: “We restored your answers from 12 minutes ago. Start again.” That single sentence turns a surprising behaviour into a reassuring one.

Verification Checklist


Related

Multi-Step Form State Machines

Frequently Asked Questions

Should a draft go in localStorage, sessionStorage or IndexedDB?

localStorage for anything that should survive closing the tab, which is the usual expectation for a long form. sessionStorage if the draft should die with the tab — appropriate when the answers are sensitive enough that leaving them on the device is the bigger risk. IndexedDB when the draft includes files or is large enough that synchronous writes become noticeable, since it is asynchronous and has a far larger quota. For a typical wizard of text answers, localStorage with a debounced write is the right default.

How long should a draft live?

Long enough to cover the interruption you are actually protecting against, which is usually hours rather than weeks. Seven days is a reasonable outer bound for a checkout or an application form; beyond that the answers are stale, prices and availability have moved, and offering them back does the reader no favours. Store the timestamp and check it on load rather than relying on the browser to clean up, because it will not.

Should the current step be part of the persisted draft?

Save it as a hint, never as state. On load, recompute status from the answers first, then honour the saved step only if the recomputed status says that step is reachable. That way a draft written before a rule changed cannot drop the reader onto a step whose prerequisites are no longer met, and the worst case is landing one step earlier than they left off.

What should happen if the saved draft fails to parse?

Delete it and start clean. A corrupt entry means a partial write or a key collision, and there is nothing useful to recover — attempting a partial parse risks restoring half a form, which is more confusing than an empty one. Log it if you have client-side error reporting, because a pattern of corrupt drafts usually means two features are writing the same key.