The exact problem: a form needs to survive a reload without a server round trip, and the naive implementation — write the whole form object on every keystroke — blocks the main thread, fills the quota, and leaves sensitive values on a shared device.
Context and Prerequisites
This is the device-storage half of draft persistence and autosave, which covers the lifecycle and the conflict model. Here we are only concerned with getting the write path right: what is stored, when, and how the failure modes of a synchronous, quota-limited, origin-shared API are handled.
localStorage is a deceptively simple API. Three of its properties cause every problem below: it is synchronous, so a large write blocks; it is string-only, so everything is serialised; and it is shared across every tab and script on the origin, so it is neither private nor exclusively yours.
Core Pattern
const VERSION = 3;
const MIN_FIELDS_BEFORE_FIRST_SAVE = 1;
interface StoredDraft {
v: number;
formId: string;
savedAt: number;
values: Record<string, unknown>;
}
/**
* Field allow-list. Anything not named here is never written, so a field added
* next month is excluded by default rather than leaked by default.
*/
const PERSISTABLE = ['fullName', 'email', 'organisation', 'message'] as const;
const keyFor = (formId: string) => `csf.draft.${formId}`;
function serialise(formId: string, values: Record<string, unknown>): string {
const kept = Object.fromEntries(
Object.entries(values).filter(([k, v]) =>
(PERSISTABLE as readonly string[]).includes(k) && v !== '' && v != null),
);
const draft: StoredDraft = { v: VERSION, formId, savedAt: Date.now(), values: kept };
return JSON.stringify(draft);
}
export function createLocalDraft(formId: string) {
let timer: ReturnType<typeof setTimeout> | null = null;
let lastWritten = '';
function writeNow(values: Record<string, unknown>): 'ok' | 'skipped' | 'unavailable' {
const payload = serialise(formId, values);
// Cheap guard: identical payload means nothing changed that we persist, so
// skip the write entirely rather than paying for a synchronous string write.
if (payload === lastWritten) return 'skipped';
const filled = Object.keys(JSON.parse(payload).values as object).length;
if (filled < MIN_FIELDS_BEFORE_FIRST_SAVE && lastWritten === '') return 'skipped';
try {
localStorage.setItem(keyFor(formId), payload);
lastWritten = payload;
return 'ok';
} catch {
// QuotaExceededError, or storage blocked by the browsing context.
// A draft is a nicety; the form must keep working without it.
return 'unavailable';
}
}
return {
schedule(values: Record<string, unknown>, ms = 800) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => writeNow(values), ms);
},
flush(values: Record<string, unknown>) {
if (timer) clearTimeout(timer);
return writeNow(values);
},
load(maxAgeMs = 7 * 24 * 3_600_000): StoredDraft | null {
let raw: string | null = null;
try { raw = localStorage.getItem(keyFor(formId)); } catch { return null; }
if (!raw) return null;
try {
const d = JSON.parse(raw) as StoredDraft;
if (d.v !== VERSION || d.formId !== formId) return null;
if (Date.now() - d.savedAt > maxAgeMs) { this.clear(); return null; }
return d;
} catch {
this.clear(); // corrupt or foreign payload — nothing to salvage
return null;
}
},
clear() {
if (timer) clearTimeout(timer);
lastWritten = '';
try { localStorage.removeItem(keyFor(formId)); } catch { /* nothing to do */ }
},
};
}
Wiring it up needs exactly three listeners, and the third is the one most implementations miss:
const draft = createLocalDraft('contact');
form.addEventListener('input', () => draft.schedule(readValues(form)));
// Fires when a tab is backgrounded, hidden or reclaimed — the only lifecycle
// event that is reliable on mobile, where unload and beforeunload are not.
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') draft.flush(readValues(form));
});
// After the SERVER confirms. Clearing on submit loses the draft when the
// request fails, which is the moment the reader most needs it.
onSubmitConfirmed(() => draft.clear());
Step-by-Step Walkthrough
-
Namespace the key.
csf.draft.contactwill not collide with an analytics library’sdraft. Validate theformIdinside the payload too, so a collision is detected rather than trusted. -
Version the payload. Bump
VERSIONwhenever the stored shape changes, and let old drafts be discarded on load. Migrating a week-old draft is more risk than value. -
Allow-list the fields. Naming what to keep, rather than what to drop, means a field added later is excluded until somebody decides otherwise.
-
Debounce, then dedupe. The debounce collapses a burst; the payload comparison skips the write entirely when nothing persisted changed. Together they turn dozens of synchronous writes per sentence into roughly one.
-
Flush on
visibilitychange. This is the event that fires when a phone reclaims the tab.beforeunloaddoes not fire reliably on mobile, andunloadis worse. -
Validate on load, then announce. Replay the validators over the restored answers so a draft written under older rules is corrected, then tell the reader in a polite live region that answers were restored — and give them a way to discard.
Failure Modes and Edge Cases
1. The quota is exceeded
setItem throws synchronously and the exception name varies by browser. Treat any throw as “storage unavailable”, keep the form working, and consider telling the reader once. The commonest cause is a file or an image encoded into the draft, which should not be there at all — store an upload reference instead.
2. Storage is blocked entirely
Some browsing contexts make even reading localStorage throw, not return null. Every access — read, write and remove — needs its own guard. A single unguarded getItem at module scope will break the whole form in those contexts.
3. A second tab overwrites the draft
Both tabs write the same key and the last one wins with no signal. Listening for storage gives you the signal:
// Fires in OTHER tabs on the same origin, never in the tab that wrote.
window.addEventListener('storage', (e) => {
if (e.key !== keyFor(formId)) return;
if (e.newValue === null) return; // the other tab submitted and cleared
onSiblingWrote(JSON.parse(e.newValue) as StoredDraft);
});
What to do with that signal is a design decision, covered in resolving conflicts when restoring a draft. What you must not do is nothing.
4. Sensitive values reach the draft
The allow-list is the mechanism, but it only works if it is reviewed. Passwords, card details, one-time codes, and anything that would be regulated at rest do not belong in device storage at all. If the form has such fields, the safest structure is to keep them in a separate component that never routes values through the draft path.
5. The draft outlives its usefulness
An expiry check on load costs one comparison and prevents a reader being offered answers from a month ago, when prices, availability and their own intent have all moved on. Seven days is a reasonable ceiling for most forms and far too long for some.
The cost of the synchronous write is worth knowing in numbers, because it is what decides the debounce interval:
Verification Checklist
Related
- Draft Persistence and Autosave — the lifecycle this write path sits inside
- Resolving Conflicts When Restoring a Draft — what to do when two copies disagree
- Best Practices for Uncontrolled Form State — why a restore must dispatch an input event
← Draft Persistence and Autosave
Frequently Asked Questions
How much can I safely store in localStorage?
Assume around five megabytes per origin, shared with everything else on it, and treat that as a ceiling rather than a budget. A form of text answers will use kilobytes; the moment a file or a base64 image enters the payload you are in a different regime and should store an upload reference instead. Because the API is synchronous, size also costs time — a megabyte-scale write is measurable on a mid-range phone, which is another reason to keep the payload to the answers alone.
Why visibilitychange rather than beforeunload?
Because beforeunload does not fire reliably when a mobile browser reclaims a backgrounded tab, which is the exact scenario the draft exists for. visibilitychange fires when the tab is hidden, which covers switching apps, locking the phone and closing the tab, and it fires early enough for a synchronous write to complete. Keep a beforeunload flush as well if you like, but treat it as the redundant one.
Should the restore happen automatically or on request?
Restore the values automatically, then tell the reader and let them undo it. Making the reader click ‘restore’ before they can see anything means an extra step for the common case where the draft is exactly what they wanted. Filling the form silently is the other failure — a reader who does not know why the fields are populated cannot trust them. Restore, announce politely, and offer ‘start again’.
Does this work with uncontrolled inputs?
Yes, and it is slightly simpler: read the values with FormData at flush time rather than mirroring them into state on every keystroke. Restoring means setting each input’s value and dispatching an input event so anything else listening — validation, dirty tracking — sees the change, since a programmatic assignment fires nothing on its own.