Engineering

Autosave without losing the last sentence

When to save, how to ignore late responses, what to do when two tabs edit one draft, and telling the writer the truth about what is saved.

8 min read

Autosave is the feature writers notice only when it fails. A save that runs too often wastes bandwidth and makes the page sluggish. A save that runs too rarely loses the last paragraph when a laptop lid closes. A save that returns out of order overwrites a newer draft with an older one. Getting autosave right means deciding when to save, what to do when the network is slow, and what the user is told at every stage.

Save after a pause, and also on a timer

Saving on every keystroke sends a request for each letter. Saving on a fixed timer, say every 30 seconds, means up to 30 seconds of work can be lost. A common pattern combines the two: save once the writer pauses for a short time, and also save at most every few seconds while they keep typing. That bounds both the request rate and the risk:

Code example
function createAutosave(save: (html: string) => Promise<void>) {
  const PAUSE_MS = 800;     // save once typing has paused this long
  const MAX_WAIT_MS = 5000; // and never go longer than this without saving
  let pending: string | null = null;
  let pauseTimer: number | undefined;
  let firstChangeAt: number | undefined;

  async function flush() {
    window.clearTimeout(pauseTimer);
    firstChangeAt = undefined;
    if (pending === null) return;
    const html = pending;
    pending = null;
    await save(html);
  }

  return function onChange(html: string) {
    pending = html;
    firstChangeAt ??= Date.now();
    window.clearTimeout(pauseTimer);
    const waited = Date.now() - firstChangeAt;
    const delay = Math.max(0, Math.min(PAUSE_MS, MAX_WAIT_MS - waited));
    pauseTimer = window.setTimeout(flush, delay);
  };
}

The function keeps only the latest HTML, so a burst of changes produces one request. The MAX_WAIT_MS check stops a writer who never pauses from going unsaved for long.

Ignore responses that arrive late

Two saves can be in flight at once. The first sends a draft at 10:00:01, the second at 10:00:03, and the second response arrives first. If the client accepts whatever response comes last, the older draft wins on screen and may be the version stored on the server. Number the saves and accept only the response to the latest one, or compare a version number the server returns. The cursor-jump article covers the same bug from the editor side; here the bug lives in the save path.

Code example
let latestSent = 0;

async function saveVersioned(html: string): Promise<"latest" | "superseded"> {
  const version = ++latestSent;
  const response = await fetch("/api/drafts/current", {
    method: "PUT",
    headers: { "Content-Type": "application/json", "X-Draft-Version": String(version) },
    body: JSON.stringify({ html }),
  });
  if (!response.ok) throw new Error("save failed: " + response.status);
  // A newer save was sent while this one was in flight. The caller should not
  // show this response as the current state; the newer response decides it.
  return version === latestSent ? "latest" : "superseded";
}

The server must also check the version. If an older version arrives after a newer one has been stored, it should reject the write with a conflict status rather than overwrite the newer content.

Two tabs, one draft

A writer who opens the same draft in two tabs will eventually save from both. A last-write-wins rule loses one set of edits without telling anyone. A safer design sends the version the client last read, and the server accepts the write only when that version is still current. When it is not, the server returns a conflict, and the client keeps the local draft and asks the writer what to do. Silently merging two HTML documents is rarely correct, so it is better to show both versions than to guess.

Offline is not the same as a failed save

navigator.onLine reports whether the device has a network connection, not whether your server is reachable. Treat it as a hint. The reliable signal is a failed request. When a save fails, keep the draft in the browser so nothing is lost, and retry with a delay that grows after each failure. Keep the amount of local data small. Local storage holds a few megabytes per site and is shared by all tabs, so a long document should go to IndexedDB, and only the latest unsaved draft and its version number should be kept in memory or in a small local record.

Save when the page is being left

A closed tab cannot wait for a request. The pagehide event is the most reliable moment to send a last save, and fetch with keepalive: truelets the request outlive the page. Browsers limit the size of keepalive requests, roughly 64 KB across all pending ones in Chromium, so a large document will not fit. Send the change set rather than the whole document in that case, or accept that a very large final save may not complete and show the writer a warning before they leave.

Tell the writer what happened

A status line is the interface for autosave. Use three states and make each one true: “Saving…” while a request is in flight, “Saved at 10:04” after the latest request succeeds, and a clear warning with a retry action when saves fail. Avoid a permanent “Saved” label that stays on screen while the request is failing. A writer who trusts that label will close the tab with unsaved work.

A short test list


RichTextEditor is a perpetual-licence JavaScript editor — one purchase, self-hosted, no metered editor loads. Download the evaluation or see how it compares.