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,
};
}
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.
Verification #
- 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.
- Change stock server-side while away. Confirm the resume point moves back to the affected step and the change list names the item.
- Change the price. Confirm the old and new totals are both shown before the payment step is reachable.
- Search storage for card data. After a full run, grep IndexedDB and
localStoragecontents for the test card number; zero matches is the pass condition, and this belongs in CI. - 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.
Related #
- Form State Recovery & Multi-Step Wizards — the snapshot machinery this builds on
- Persisting File Uploads Across a Reload with IndexedDB — the attachment half of the same flow
- Implementing Draft Recovery for Long-Form Editors — the same discipline where the content, not the price, is the risk