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
The draft's own lifecycle, running alongside the form's Clean means nothing is unsaved. A field change moves it to dirty and starts a debounce. When the debounce elapses, or the page is hidden, it moves to saving. A confirmed write moves it to saved, which is the state the timestamp shown to the reader comes from. A server error moves it to failed, from which a backoff retry returns it to saving while the local copy is kept. A rejection indicating the remote copy also changed moves it to conflict, which is resolved by the reader rather than automatically. A confirmed submission returns it to clean and deletes the stored draft. clean nothing unsaved a change dirty debounce running settled saving write in flight 2xx saved timestamp shown failed back off, retry 409 conflict the reader decides The dashed line is the retry: the local copy is never discarded while a write is outstanding, so a failure costs latency rather than data. A confirmed submission returns every phase to clean and deletes the stored copy. Five phases the "Saving…" label is derived from An indicator that only ever says "Saved" is a decoration; one derived from these phases is a promise the reader can rely on.

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.

Device storage or the server — the trade is not close to even A device-local draft survives a reload and a crash but not a device change, works fully offline, cannot conflict because there is only one copy, leaves data on the device until it is deleted, and costs almost nothing to build. A server draft survives a device change and can be picked up elsewhere, requires a connection to save, can conflict with edits from another session, keeps the data under your existing access controls, and costs an endpoint, an authorisation model and a conflict interface. Many teams end up with both: device storage as the always-available fallback, and the server as the durable copy. Property Device storage Server draft survives reload, crash, close a change of device offline fully needs a connection conflicts impossible — one copy possible, must be designed for privacy left on the device under your access controls Many forms want both: device storage as the always-available fallback, the server as the durable copy that follows the reader. If you ship both, the device copy is the one that must never be authoritative — it cannot know what anyone else did.

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:

What each draft phase should say out loud Clean says nothing at all, because there is nothing to reassure about. Dirty says unsaved changes, so a reader who is about to close the tab knows. Saving says saving, in a polite region so it never interrupts. Saved says saved with a relative time, which is the answer to the only question a reader actually has. Failed says not saved and offers a manual retry, because a silent spinner gives them nothing to act on. Conflict names the other version and asks, rather than picking one. Phase The status line says Announced how clean nothing not at all dirty "Unsaved changes" politely, once saving "Saving…" politely saved "Saved 2 minutes ago" politely failed "Not saved. Retry" assertively — data is at risk An indicator that can only ever say "Saved" is decoration; one derived from the phase is a promise.

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

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.