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());
Three guards between a keystroke and a write An input event schedules a write. The debounce collapses a burst of keystrokes into one attempt. The attempt is then compared against the last payload written, and skipped entirely if nothing that we persist has changed — which is common, because most keystrokes change a field that is not on the allow-list or produce the same serialised result. The allow-list then strips everything not explicitly named, so a newly added field is excluded by default. Only then is the synchronous write performed, and a quota failure returns unavailable rather than throwing into the form. input event one per keystroke debounce 800ms a burst becomes one same as last? skip if unchanged allow-list strip everything else written synchronously unavailable quota or blocked The middle two guards exist for cost: a synchronous write of a large payload is measurable, and most keystrokes do not change it. The allow-list exists for safety, and it is a list of what to keep precisely so that forgetting to update it fails closed.

Step-by-Step Walkthrough

  1. Namespace the key. csf.draft.contact will not collide with an analytics library’s draft. Validate the formId inside the payload too, so a collision is detected rather than trusted.

  2. Version the payload. Bump VERSION whenever the stored shape changes, and let old drafts be discarded on load. Migrating a week-old draft is more risk than value.

  3. Allow-list the fields. Naming what to keep, rather than what to drop, means a field added later is excluded until somebody decides otherwise.

  4. 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.

  5. Flush on visibilitychange. This is the event that fires when a phone reclaims the tab. beforeunload does not fire reliably on mobile, and unload is worse.

  6. 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.

Every rejection path ends in an empty form, never a partial one A version mismatch means the stored shape predates the current code, so the entry is ignored and left for the next write to replace. A form id mismatch means something else on the origin used this key, and the entry is ignored. An age beyond the configured maximum means the draft is stale, and it is deleted as well as ignored. A parse failure means a partial write or a foreign payload, and it is deleted. In all four cases the reader gets a clean, empty form — never half a restore, which is more confusing than none. Rejected because Meaning Delete it too? version mismatch written by older code no — the next write replaces it form id mismatch a key collision no — it is not yours to delete too old, or unparseable stale, or a partial write yes — nothing to salvage Note the second row: a payload whose formId is not yours may belong to another feature, so ignore it rather than removing it. Half a restore is worse than none — a form showing three of five saved answers looks like it lost two. Log rejections in development: a steady stream of them usually means the version is being bumped on every deploy.

The cost of the synchronous write is worth knowing in numbers, because it is what decides the debounce interval:

Roughly what a localStorage write costs A payload of about two kilobytes, which is a typical text form, serialises and writes in well under a millisecond and can be written per settle without any concern. Around fifty kilobytes, which is a long free-text answer, costs a small but measurable fraction of a frame. Around five hundred kilobytes, which usually means structured data that should not be in a draft, costs several milliseconds per write and is felt on a mid-range phone. Anything with an encoded file in it is in a different regime entirely and belongs in IndexedDB or an upload reference. Payload size Serialise + write What to do ~2 kB — a text form well under 1ms write on settle, no concern ~50 kB — long free text a fraction of a frame keep the debounce; do not shorten it ~500 kB — structured data several ms trim the payload, or move to IndexedDB an encoded file tens of ms store an upload reference instead The write is synchronous, so every one of these numbers is time the main thread is not handling input.

Verification Checklist


Related

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.