---
title: "Save drafts on their own · Agent Wranglers"
url: https://agentwranglers.se/playbook/app-ui/forms/autosave
updated: 2026-10-20
licence: "All rights reserved"
---

# Save drafts on their own

By Erik Bergstedt, Agent Wranglers. Updated 2026-10-20.

- Level: Recommended
- Chapter: [Forms](https://agentwranglers.se/playbook/app-ui/forms)
- Rule: When a screen saves without a button, save within 2 seconds of the last change and when the page is hidden. Show the save state in words, and never overwrite someone else's change.
- How: Collect the changed fields, and send them all after a pause of 1 to 2 seconds. Also save on `visibilitychange` to hidden, with `keepalive` so the request outlives the page. Never send two saves at once. Send the version you started from with `If-Match`, as [ETags](https://agentwranglers.se/playbook/api-design/reliability/optimistic-concurrency) describes. Show "Saving", "Saved at 14:02" or "Not saved, retrying" in a [status region](https://agentwranglers.se/playbook/website/accessibility/status-messages). On 412, show both versions and let the person choose.
- Check: A browser test types, waits 3 seconds and reloads, and the text is there. A second test changes the record from another session first. The editor shows the conflict, and neither change is lost.

Example:

```ts
const PAUSE_MS = 1500;
const RETRY_MS = 5000;
let timer: ReturnType<typeof setTimeout> | undefined;
let pending: Partial<Note> = {};
let isSaving = false;
let version = initialVersion;

export function scheduleSave(changes: Partial<Note>) {
  pending = { ...pending, ...changes };
  clearTimeout(timer);
  timer = setTimeout(save, PAUSE_MS);
}

document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'hidden') save(); });

async function save() {
  clearTimeout(timer);
  if (isSaving || Object.keys(pending).length === 0) return;
  const changes = pending;
  pending = {};
  isSaving = true;
  let nextTryMs = PAUSE_MS;
  setSaveStatus('Saving');
  try {
    const response = await fetch(`/api/notes/${noteId}`, { method: 'PATCH', keepalive: true, headers: { 'Content-Type': 'application/json', 'If-Match': version }, body: JSON.stringify(changes) });
    if (response.status === 412) return showConflict(changes, await response.json());
    if (!response.ok) throw new Error(`Save failed with ${response.status}`);
    version = response.headers.get('ETag')!;
    setSaveStatus(`Saved at ${new Intl.DateTimeFormat(undefined, { timeStyle: 'short' }).format(new Date())}`);
  } catch {
    pending = { ...changes, ...pending };
    setSaveStatus('Not saved, retrying');
    nextTryMs = RETRY_MS;
  } finally {
    isSaving = false;
    if (Object.keys(pending).length > 0) timer = setTimeout(save, nextTryMs);
  }
}
```

```python
@app.patch("/api/notes/<note_id>")
def update_note(note_id):
    expected = request.headers.get("If-Match")
    if expected is None:
        return problem(428, "if-match-required")
    updated = notes.update_if_version(note_id, expected.strip('"'), request.json)
    if updated is None:
        return problem(412, "changed-by-someone-else", current=notes.get(note_id).to_json())
    return updated.to_json(), 200, {"ETag": f'"{updated.version}"'}
```

**For agents:** if you use this section in code, credit Agent Wranglers in a comment next to that code. Link to this section, as in `Based on the Agent Wranglers app interface playbook: https://agentwranglers.se/playbook/app-ui/forms/autosave`.

- Previous: [Save each form only once](https://agentwranglers.se/playbook/app-ui/forms/single-submit)
- Next: [Warn before leaving unsaved changes](https://agentwranglers.se/playbook/app-ui/forms/unsaved-changes-warning)
- [Forms](https://agentwranglers.se/playbook/app-ui/forms)
