This page works through the highest-stakes case of the pattern in Form State Recovery & Multi-Step Wizards: a flow where restoring the wrong thing costs money.

The exact problem #

A user is three steps into checkout — account, address, shipping — when the tab crashes. Their cart is worth £180, they typed a full address, and they had chosen next-day delivery.

The naive recovery restores everything and drops them back on the payment step. Except: one item went out of stock twenty minutes ago, next-day delivery is no longer available for the new dispatch time, and the total is now £174.50. The user pays without noticing, then contacts support because the confirmation does not match what they saw.

Restoring form state is the easy half. The hard half is that a checkout snapshot describes a world — prices, stock, shipping windows — that keeps moving while the tab is gone.

Zero-to-working restore #

// restoreCheckout.ts
export type RestoreOutcome =
  | { status: 'none' }
  | { status: 'expired' }
  | { status: 'ready'; state: WizardState; changes: CartChange[]; resumeAt: StepId };

const MAX_AGE_MS = 6 * 60 * 60 * 1000;    // a checkout older than 6h is stale

export async function restoreCheckout(flowKey: string): Promise<RestoreOutcome> {
  const snapshot = await loadSnapshot(flowKey);       // schema + expiry checked inside
  if (!snapshot) return { status: 'none' };
  if (Date.now() - snapshot.updatedAt > MAX_AGE_MS) {
    await clearSnapshot(flowKey);
    return { status: 'expired' };
  }

  // The snapshot's view of the world may be minutes or hours old.
  const live = await fetchCartState(snapshot.values.cart?.cartId as string);
  const changes = diffCart(snapshot.values.cart as CartSnapshot, live);

  // A change can invalidate a step the user had already completed.
  const invalidated = new Set<StepId>();
  for (const change of changes) {
    if (change.kind === 'out-of-stock' || change.kind === 'quantity-reduced') invalidated.add('review');
    if (change.kind === 'shipping-unavailable') invalidated.add('shipping');
    if (change.kind === 'price-changed') invalidated.add('review');
  }

  const order: StepId[] = ['account', 'address', 'shipping', 'review'];
  const earliestInvalid = order.find((s) => invalidated.has(s));
  const resumeAt = earliestInvalid ?? snapshot.current;

  return {
    status: 'ready',
    state: {
      ...snapshot,
      // Completed steps after the earliest invalid one must be re-confirmed.
      completed: snapshot.completed.filter((s) => order.indexOf(s) < order.indexOf(resumeAt)),
    },
    changes,
    resumeAt,
  };
}
How a live re-price moves the resume point An upper row shows the snapshot with account, address and shipping marked complete and review pending. A middle box shows re-pricing detecting an out-of-stock item and an unavailable shipping option. The lower row shows the adjusted state where shipping is no longer marked complete and the user resumes at the shipping step. snapshot as saved 1 account ✓ 2 address ✓ 3 shipping ✓ 4 review re-price against the server 1 item out of stock · next-day no longer offered · total −£5.50 state after restore 1 account ✓ 2 address ✓ 3 shipping — resume here 4 review

Telling the user what changed #

// ResumeCheckout.tsx
export function ResumeCheckout({ outcome, onResume, onDiscard }: Props) {
  if (outcome.status !== 'ready') return null;
  const { changes, resumeAt, state } = outcome;
  const minutes = Math.max(1, Math.round((Date.now() - state.updatedAt) / 60000));

  return (
    <section role="alertdialog" aria-labelledby="resume-title">
      <h2 id="resume-title">Pick up where you left off?</h2>
      <p>We saved your checkout {minutes} minutes ago. Payment details are never saved.</p>

      {changes.length > 0 && (
        <>
          <h3>What changed while you were away</h3>
          <ul>
            {changes.map((c) => (
              <li key={c.id}>{describeChange(c)}</li>   /* "Blue mug — now out of stock" */
            ))}
          </ul>
        </>
      )}

      <button type="button" onClick={() => onResume(resumeAt)}>
        Resume at {STEP_LABELS[resumeAt]}
      </button>
      <button type="button" onClick={onDiscard}>Start a new checkout</button>
    </section>
  );
}

Naming the resume step in the button label is a small thing that prevents a large confusion: a user who expects to land on payment and lands on shipping needs to know why before they click.

Change while away Effect on restore What the user is told
Item out of stock Remove line, resume at review “Blue mug is no longer available and has been removed”
Quantity reduced by stock Adjust line, resume at review “Only 2 of 5 mugs are available”
Price changed Update totals, resume at review “Your total is now £174.50 (was £180.00)”
Shipping option withdrawn Clear shipping step, resume there “Next-day delivery is no longer available for today”
Promo code expired Remove discount, resume at review “The code SPRING10 has expired”
Address unchanged and valid Restored silently Nothing — no news is good news

What must never be restored #

// neverPersist.ts
export const NEVER_PERSIST = new Set([
  'cardNumber', 'cardExpiry', 'cvc', 'cardholderName',
  'otp', 'smsCode', 'password',
  'giftCardPin', 'nationalId',
]);

export function sanitiseStep(values: Record<string, unknown>): Record<string, unknown> {
  return Object.fromEntries(Object.entries(values).filter(([key]) => !NEVER_PERSIST.has(key)));
}

Keep the list in one module, next to a test that fails if a new payment field is added without a decision. That test is the control that actually holds over time — a code-review convention will not survive twenty sprints. Everything here is also subject to the storage-quota trade-off in Handling QuotaExceededError When Persisting Session State: a checkout snapshot is small, but it competes with everything else the origin stores.

Snapshot lifecycle, ending at order placement A four-stage lifecycle. Written during the flow at each step transition. Persisted while the tab is gone. Read once on the next visit, producing a resume prompt. Deleted in the same transaction that records the placed order, with a note that skipping this step lets a completed checkout be resumed later. written per step debounced + on transition survives the crash IndexedDB, schema-versioned read once, re-priced prompt names the step deleted on success same transaction skip the delete and the next visit offers to resume a checkout the user already paid for Nothing is shown until the world has been re-checked Four sequential steps: read the snapshot, fetch live cart state, diff the two, and choose the resume step. Only then does the prompt render. A note warns that showing the prompt before re-pricing is what produces a surprise at the payment step. read snapshot fetch live cart price · stock · shipping diff the two choose step earliest invalid prompting before the re-price is what produces a surprise total at the payment step

Verification #

  1. Crash mid-flow. Close the page context without unload handlers on step three, reopen, and confirm the prompt names step three and the snapshot age.
  2. Change stock server-side while away. Confirm the resume point moves back to the affected step and the change list names the item.
  3. Change the price. Confirm the old and new totals are both shown before the payment step is reachable.
  4. Search storage for card data. After a full run, grep IndexedDB and localStorage contents for the test card number; zero matches is the pass condition, and this belongs in CI.
  5. Complete an order. Confirm the snapshot is gone immediately, and that reopening the checkout offers nothing to resume.

Recovery and analytics #

A recovered checkout is a different funnel event from a fresh one, and treating them as the same distorts every conversion number you have. Instrument three things separately: how often a snapshot exists when a checkout starts, how often the user accepts the resume prompt, and how often a resumed checkout completes.

Those numbers answer questions the rest of the funnel cannot. A low acceptance rate usually means the prompt is appearing when it should not — after a deliberate abandonment, or with a snapshot so old it is no longer relevant — and the fix is a shorter expiry rather than better copy. A high acceptance rate with low completion usually means the re-price step is surprising people, and the fix is showing the changes more prominently before they reach payment.

Frequently asked questions #

Can I restore the user’s card details after a crash?

No. Card numbers, CVCs and one-time codes must never reach client storage. Restore everything else and say plainly that payment details need re-entering.

What if the price changed while the tab was gone?

Re-price on restore and show the difference before the payment step. Charging a total different from the one restored is a support incident waiting to happen.

Should recovery apply if the user deliberately closed the tab?

Offer it, do not apply it. A prompt naming the step and the snapshot’s age is fine; silent repopulation is not.