Autosave is the feature readers never mention when it works and never forgive when it does not. It is also the feature most likely to be added late, wired to a setInterval, and shipped without anyone deciding what should happen when the saved copy and the server disagree — which is the only part that is actually hard.
Problem Statement
The sub-problem is reconciliation under uncertainty. Saving is easy. The difficulty is that a draft is a claim about what the reader intended at some past moment, and by the time it is restored, three other things may have changed: the record on the server, the validation rules, and the reader’s own memory of what they were doing. A draft system that ignores any of those produces a specific, reportable failure — silently overwriting a colleague’s edit, restoring answers that no longer validate, or filling a form the reader believed was blank.
This topic applies to any form where the cost of losing input is higher than the cost of storing it: long applications, content editors, anything filled on a phone, anything behind an unreliable connection. It does not apply to short forms, and it should be actively avoided for sensitive input — a draft is a copy of data that outlives the session, and that is a liability as often as it is a feature.
Four questions have to be answered before any code, and answering them differently produces genuinely different systems:
- Where does the draft live? Device storage is instant, private to the device, and lost when the device is. A server draft survives a device change and can be shared, at the cost of a request and an authorisation model.
- What triggers a write? Time, input, or a lifecycle event — and in practice all three, because each covers a gap the others leave.
- What is stored? The answers, or the answers plus everything derived from them. Storing derived state is how a restored draft lands in an inconsistent condition.
- Who wins on conflict? The draft, the server, or the reader. Only the third is safe in the general case, and it is the one that requires interface design rather than code.
State Machine Specification
A draft has a lifecycle of its own, running alongside the form’s. Modelling it explicitly is what makes the “saving…” indicator honest — and an honest indicator is most of the perceived value of autosave.
type DraftState =
| { phase: 'clean' } // nothing unsaved
| { phase: 'dirty'; since: number } // changes not yet written
| { phase: 'saving'; attempt: number } // a write is in flight
| { phase: 'saved'; at: number } // written and confirmed
| { phase: 'failed'; at: number; retryable: boolean } // the write did not land
| { phase: 'conflict'; local: Draft; remote: Draft }; // both sides changed
The conflict phase only exists for server-side drafts, and the temptation is to leave it out. Doing so does not remove conflicts; it removes your ability to notice them, and the last write silently wins.
| Event | From | To | Notes |
|---|---|---|---|
| field changed | any | dirty |
starts the debounce; cancels a pending saved fade |
| debounce elapsed | dirty |
saving |
one write per settle, not one per keystroke |
| write confirmed | saving |
saved |
record the timestamp; it is what the reader is shown |
| write rejected, 5xx | saving |
failed (retryable) |
back off and retry; keep the local copy |
| write rejected, 409 | saving |
conflict |
the remote changed under us — ask, do not merge |
| page hidden | dirty |
saving |
flush; this is the write that saves a phone session |
| submit confirmed | any | clean |
delete the draft, after the server confirms |
Core Implementation
type Values = Record<string, unknown>;
interface DraftStore {
read(key: string): Promise<Draft | null>;
write(key: string, draft: Draft): Promise<void>; // may reject with a ConflictError
remove(key: string): Promise<void>;
}
/**
* The autosave controller. Deliberately storage-agnostic: the same logic drives
* a localStorage draft and a server draft, and only the DraftStore differs.
*/
export function createAutosave(opts: {
key: string;
store: DraftStore;
debounceMs?: number;
onState: (s: DraftState) => void;
/** Allow-list of persistable fields. Anything not named here is never written. */
persistable: readonly string[];
}) {
const { key, store, debounceMs = 800, onState, persistable } = opts;
let timer: ReturnType<typeof setTimeout> | null = null;
let pending: Values | null = null;
// The controller for the in-flight write, so a newer save supersedes an older one.
let inflight: AbortController | null = null;
let attempt = 0;
const pick = (values: Values): Values =>
Object.fromEntries(Object.entries(values).filter(([k]) => persistable.includes(k)));
async function flush(): Promise<void> {
if (pending === null) return;
const payload = pick(pending);
pending = null;
// Supersede any write still in flight: it carries older values by definition.
inflight?.abort();
inflight = new AbortController();
onState({ phase: 'saving', attempt });
try {
await store.write(key, { values: payload, savedAt: Date.now() });
attempt = 0;
onState({ phase: 'saved', at: Date.now() });
} catch (err) {
if ((err as Error).name === 'AbortError') return; // superseded, not failed
if (err instanceof ConflictError) {
onState({ phase: 'conflict', local: err.local, remote: err.remote });
return; // never auto-merge
}
attempt += 1;
onState({ phase: 'failed', at: Date.now(), retryable: attempt < 5 });
if (attempt < 5) {
// Exponential backoff with a ceiling; the local copy is still intact.
pending = payload;
timer = setTimeout(flush, Math.min(30_000, 2 ** attempt * 500));
}
}
}
return {
/** Call when values settle — on change, not on every keystroke of a long paste. */
schedule(values: Values): void {
pending = values;
onState({ phase: 'dirty', since: Date.now() });
if (timer) clearTimeout(timer);
timer = setTimeout(flush, debounceMs);
},
/** Call on visibilitychange and before navigation: writes now, ignores the debounce. */
flushNow(): Promise<void> {
if (timer) clearTimeout(timer);
return flush();
},
/** Call once the SERVER has confirmed the submission, never before. */
async discard(): Promise<void> {
if (timer) clearTimeout(timer);
inflight?.abort();
pending = null;
await store.remove(key);
onState({ phase: 'clean' });
},
};
}
Three details carry most of the reliability. A newer write aborts the older one, so a slow save cannot land after a fast one and resurrect stale answers — the same reasoning as cancelling stale async validation with AbortController. A conflict is never merged automatically. And discard runs after server confirmation, not on submit, so a failed submission leaves the draft intact.
Integration Guidance
Autosave subscribes to the same settled-value events that dirty and pristine state tracking already produces, which means it needs no new instrumentation in the fields — if the form knows a field became dirty, it knows enough to schedule a save. Reusing that signal also gets the normalisation for free: a value that is not really different should not trigger a write.
For a wizard, the draft is the answers map from the multi-step machine and nothing else. Persisting the machine’s derived status is the mistake that produces a resume on the wrong step.
The interface side matters more than the storage side. A reader needs to know three things at a glance: whether their work is safe right now, when it was last safe, and what to do if it is not. One live-region-backed status line covers all three, and it must be polite rather than assertive — a save confirmation that interrupts what a screen reader reader is currently hearing is worse than no confirmation at all.
Edge Cases and Failure Modes
The reader has the form open twice. Two tabs on the same draft key will overwrite one another silently with device storage, and produce a stream of conflicts with a server draft. Listen for storage events to detect the sibling tab, and either lock the second one out or make the conflict visible.
The connection returns mid-backoff. A failed write that is retrying on a thirty-second ceiling looks broken when the connection has been back for twenty-five of those seconds. Listen for online and flush immediately rather than waiting out the timer.
The draft is bigger than the quota. A form with a long free-text answer plus a base64 image will exceed a five-megabyte quota faster than anyone expects, and setItem throws synchronously. Keep files out of the draft entirely — store a reference and re-upload — and treat quota failures as a reason to fall back to a server draft rather than as an error to show.
Submitting from a stale draft. A draft restored after a rule change may contain values the current schema rejects. Validate on restore, not only on submit, so the reader is told immediately rather than after they press the button.
Autosaving a form the reader is abandoning. Someone who opens a form, types two characters and leaves has not asked you to remember anything. A draft that greets them on their next visit is noise. Requiring a minimum amount of input — one completed field, or a step advanced — before the first write removes most of that.
What the reader is told is most of what autosave is worth, so the status line deserves the same care as the write path:
Troubleshooting Reference
| Symptom | Diagnostic step | Recovery |
|---|---|---|
| “Saved” shows but nothing was written | Check whether the write promise is awaited before the phase changes | Move the saved transition inside the resolved branch |
| Draft restores on a form the reader never filled | Log how many fields were non-empty at the first write | Require a minimum before the first save |
| Saves stop after a network blip | Check whether the backoff has hit its ceiling with no online listener |
Flush on online; reset attempt on success |
| Restored draft fails validation immediately | Compare the draft’s version against the current schema version | Validate on restore and tell the reader which answers need another look |
| Two tabs fight | Watch for alternating writes in the storage event log |
Detect the sibling and surface it; do not merge silently |
Testing and QA Hooks
The controller is testable without a DOM by supplying a fake DraftStore. The sequences worth asserting are the ones where timing is the bug: a write scheduled and then superseded, a failure followed by a success, a flush racing a debounce, and a discard arriving while a write is in flight. Each of those is two lines of test and a class of production incident.
For end-to-end coverage, expose the phase as a data attribute on the status element — data-draft-phase="saving" — so a browser test can wait on the real state rather than on the word “Saving” appearing, which a copy change will break. Pair that with an assertion that the live region announced at most once per settle: an autosave that announces every keystroke is technically working and practically unusable with a screen reader.
Common Pitfalls
- Saving on an interval. A timer that fires every ten seconds writes when nothing changed and misses the change made at second nine. Debounced writes plus lifecycle flushes cover both.
- Announcing every save assertively. “Saved” interrupting mid-sentence, every few seconds, is the fastest way to make a form unusable with a screen reader.
- Deleting the draft on submit rather than on confirmation. A failed submission then loses everything, which is the exact moment the draft was most needed.
- Storing derived state. Validity, step status and progress are all recomputable, and persisting them means restoring conclusions drawn under rules that may have changed.
- Treating a conflict as an error. Two people editing the same record is normal. The interface for it is a design problem, not an exception to log.
Related
- Autosaving Form Drafts to localStorage — the device-storage implementation end to end
- Resolving Conflicts When Restoring a Draft — what to do when both copies changed
- Persisting Wizard Progress Across Reloads — the same problem with a step machine on top
- Dirty and Pristine State Tracking — the signal autosave subscribes to
← Form State Fundamentals & Architecture
Frequently Asked Questions
How often should a draft actually be written?
On settle rather than on a schedule: debounce around eight hundred milliseconds after the last change, and flush immediately on visibilitychange, on step change and before navigation. An interval-based save both writes when nothing changed and misses the change made just before the reader closed the tab. The lifecycle flush is the one that matters most on mobile, where a tab can be reclaimed without any unload event firing.
Should autosaved values be validated?
Validate on restore, not on write. Writing an invalid draft is correct — half-finished input is exactly what a draft is for, and refusing to save it defeats the purpose. Restoring is different: the rules may have changed since, so replay the validators as the draft loads and show the reader which answers need another look before they reach the submit button.
What should the status indicator actually say?
Three states, in the reader’s terms: unsaved changes, saving, and saved with a relative time. Put it in a polite live region so a screen reader reader is told without being interrupted, and make the failed state actionable — ‘Not saved. Retrying…’ with a manual retry control beats a silent spinner. Never show only ‘Saved’, because an indicator that cannot say anything else tells the reader nothing.
Is autosaving to localStorage a privacy problem?
It can be, and the default should be caution. localStorage is readable by any script on the origin and survives until something deletes it, so a draft is a copy of the reader’s data sitting on a possibly shared device. Use an allow-list of persistable fields so a newly added field is excluded until someone deliberately includes it, never persist payment details, credentials or one-time codes, and set an expiry so an abandoned draft does not live indefinitely.