Form Autosave & Draft Persistence: Chat Prompts
Autosave is the difference between users trusting your app with a 2,000-word doc and users typing in Notes.app first because they don't trust your app not to lose their work. The pattern is simple in concept (save as the user types) and full of subtle traps in practice (network errors, conflicting tabs, browser refreshes, tabs closing before the save lands, server-side validation rejecting a draft, version conflicts when another collaborator edits at the same time).
The hard parts: deciding when to save (every keystroke vs. on idle vs. on blur), handling offline (queue + retry), surviving page refresh (localStorage / IndexedDB before the network), and conflict resolution when two clients diverge. Get it right and your app feels indestructible. Get it wrong and a user types for 30 minutes and loses everything.
When Autosave Belongs (and Doesn't)
Use autosave for:
- Long-form text (docs, blog posts, comments over 100 chars)
- Multi-step forms (don't lose progress on tab refresh mid-step 3 of 5)
- Settings pages (changing 8 fields, rage-quit shouldn't lose the 7 unsaved)
- Anything where the user is investing more than 30 seconds of input
Don't use autosave for:
- Single-step transactional forms (login, checkout, search) — the act of submitting is the persistence
- Anything with destructive side effects on save (sending an email, charging a card)
- Anything where a partial state is invalid and confusing (filing taxes mid-keystroke saves a "draft tax return"?)
For the gray area, explicit "Save Draft" + autosave-on-idle is often the right hybrid.
Debounced Autosave on Change
The default pattern. Save N seconds after the user stops typing.
I'm building autosave for [editing surface, e.g. "a long-form post editor"] in my Next.js + TanStack Query app.
Pattern:
- User types in a [text editor / form]
- Debounce keystrokes (1-2 seconds)
- On debounced change, send PATCH to /api/posts/[id] with the current content
- Show subtle status indicator: "Saving…", "Saved", "Save failed (retry)"
Implement:
1. `useDebouncedCallback` hook (or library — lodash.debounce / use-debounce)
2. Mutation hook with optimistic save state (saving / saved / error)
3. Status indicator component — small, subtle, in a corner of the editor
4. Cancel any in-flight save when a newer save is debounced (last-write-wins for the same client)
5. On error: keep the user's input visible, retry up to 3x with exponential backoff
6. Disable form submit during in-flight save (or queue submit)
Edge cases:
- User edits while save is in flight → newer save replaces older
- Network goes offline → queue and surface "Offline" indicator
- User closes tab before debounce fires → fire save immediately on `beforeunload` (best effort)
- Save fails permanently → show "Failed — your changes are stored locally. [Retry] [Discard]"
Stack: Next.js App Router + TanStack Query v5. Editor is [TipTap / Lexical / textarea / react-hook-form].
Output: a useAutosave(value, saveFn, options) hook + status indicator component.
Local Persistence (localStorage / IndexedDB) Before Network
Network can fail; tabs can crash. Save locally first, then sync to server. On reload, restore from local if newer than server.
I want to add local-storage-backed draft persistence to my form so:
1. As the user types, content is debounced (300ms) and written to localStorage under key `draft:[resource]:[id]`
2. Separately, debounced (2s) network save sends to server
3. On page reload:
- Fetch server version
- Compare local and server `updatedAt`
- If local is newer (i.e. unsaved changes exist), prompt: "You have unsaved changes from [time ago]. [Restore] [Discard]"
- If server is newer (e.g. saved on another device), use server
4. After successful network save, clear localStorage entry for that draft
Implement:
- The hook that wires both layers
- The conflict-resolution prompt UI
- A cleanup utility that removes stale drafts older than 7 days from localStorage
- Considerations for data size: localStorage is 5MB; for larger drafts (rich text with images), use IndexedDB via `idb` library
Show me both the localStorage and IndexedDB variants. When should I switch from localStorage to IndexedDB?
Stack: Next.js + TanStack Query.
The IndexedDB switch threshold is roughly: drafts >50KB on average, or you need to store more than ~50 drafts per user.
Save on Blur + Save on Idle
A more conservative pattern than per-keystroke debounce. Saves only at meaningful pause points.
I want autosave that fires on:
1. Field blur (user moves to next field) — immediate save
2. Idle for 5 seconds (no keystrokes for 5s) — debounced save
3. Page unload (`beforeunload`) — synchronous save attempt via navigator.sendBeacon
4. Explicit "Save" button click — immediate save with full feedback
Why this pattern: avoids saving mid-thought, reduces server load vs. per-keystroke debounce, still feels safe to the user.
Build:
- A form pattern with blur/idle/unload listeners
- The `sendBeacon` fallback for page-close (note: payload must be small + serializable)
- A subtle "Last saved 5s ago" indicator
- A keyboard-shortcut for explicit save (Cmd/Ctrl+S → trigger immediate save)
Stack: Next.js + react-hook-form + TanStack Query.
sendBeacon is the right primitive for save-on-unload — it queues a small POST that survives the tab closing. Don't use fetch in beforeunload — most browsers cancel it.
Multi-Step Form / Wizard Drafts
Multi-step forms are where draft persistence matters most. Lose progress mid-step-4-of-5 and users rage-quit.
I have a 5-step onboarding wizard. I want each step's data persisted as the user progresses, so refreshing or closing the tab and returning resumes from the last completed step.
Wizard steps:
1. Personal info (name, email, role)
2. Company info (size, industry)
3. Goals (multi-select)
4. Integrations (connect Slack, GitHub)
5. Confirm + finish
Build:
- A persistent draft model: `{wizardId, currentStep, data: {step1: {...}, step2: {...}, ...}, updatedAt}`
- Server endpoint: PATCH /api/onboarding-drafts/[id]
- Local fallback: localStorage key `wizard-draft:[wizardId]`
- On wizard mount: try to restore — server first, fallback to local if server is empty
- Each step's "Next" button: save current step → server, then advance
- "Back" button: navigate without losing forward progress (data persists across navigation)
- "Resume from where you left off" UX on returning — show progress bar at the saved step
- Validation per step but allow saving an invalid step as draft (server doesn't validate strictly, only completion)
Edge cases:
- User refreshes mid-step → restore current values
- User abandons wizard for a week → resume from where they left off (with a "Start Over" option)
- Server validation rejects step 4 but earlier steps are fine → save what's valid; surface the invalid
Stack: Next.js App Router + react-hook-form + TanStack Query + zod.
Rich Text Editor Autosave (TipTap / Lexical / ProseMirror)
Rich-text editors emit a stream of changes. Naive autosave on every change saves dozens of times per second.
I have a TipTap (or Lexical / ProseMirror) editor for [content type, e.g. "blog posts"]. Implement autosave that:
1. Subscribes to editor's `onUpdate` event
2. Debounces 1.5 seconds — only saves after user stops typing
3. Sends the editor state as serialized JSON (TipTap: `editor.getJSON()`)
4. Optionally sends an HTML render too for indexing
5. Tracks "dirty" state — true if local content differs from last-saved content
6. Beacon-saves on `beforeunload` if dirty
Additional polish:
- Don't save if content is unchanged (compare current JSON to last-saved JSON shallowly via stable serialization)
- Don't save if the editor is empty and the doc was empty server-side (avoid saving "" repeatedly on a new doc)
- Track word count + char count locally for a "Last saved 3s ago — 547 words" status
Show me:
- The hook (`useTipTapAutosave(editor, {saveFn, debounceMs})`)
- The status component
- A pattern for Yjs-collaborative editing where autosave coordinates with CRDT sync
Stack: Next.js + TipTap + TanStack Query.
For TipTap with Yjs, the autosave layer should snapshot the Yjs document state, not the JSON — Yjs handles conflict-free sync, and your "save" is a periodic snapshot for non-collaborative concerns (search indexing, server backup).
Conflict Resolution: Two Clients Editing the Same Draft
If a user has two tabs open, or edits on phone + laptop, autosave races.
A user edits the same draft from two tabs simultaneously. Each tab autosaves. Without coordination, last-save-wins silently destroys the other tab's edits.
Implement conflict detection:
1. Server tracks `version` integer per draft, incremented on every PATCH
2. Each save sends `{content, expectedVersion}`
3. Server responds 409 Conflict if `expectedVersion` doesn't match
4. On 409: client fetches current server state and shows "This draft was edited elsewhere. [Use yours] [Use theirs] [See diff]"
5. After conflict resolution, save with updated `expectedVersion`
Cross-tab coordination (same browser):
- Use BroadcastChannel API to emit "draft-saved" events across tabs
- When a tab receives a save event from a sibling tab with a newer version, refresh local state to match
- Avoid two tabs from the same browser racing on autosave by leader-electing one tab as the active autosaver (BroadcastChannel + locks)
Build:
- The 409 handler with conflict UI
- The BroadcastChannel-based cross-tab sync
- A pattern for deciding which tab is "leader"
Stack: Next.js + TanStack Query.
Offline Queueing
Drafts the user wrote while offline must replay when connectivity returns.
My app is sometimes used offline (mobile users on commute). Implement offline-aware autosave:
1. While online: normal debounced save to server
2. When offline (`navigator.onLine === false`): save to IndexedDB queue with timestamp
3. When connection returns: replay queue in chronological order, with conflict checks
4. UI: an "Offline — N changes pending" banner; clears when the queue drains
Implement:
- The IndexedDB queue (use `idb` library for ergonomics)
- The queue replay logic on `online` event
- Per-replay error handling: 409 conflict triggers the dialog UI; 401 prompts re-auth; 500 retries with backoff
- Survival: queue persists across browser restarts (IndexedDB does); replay on next visit if user closed without reconnecting
Stack: Next.js + TanStack Query + idb.
Status Indicator UX
The autosave indicator is small but high-stakes. Get it wrong and users distrust the system.
Design the autosave status indicator. Requirements:
1. Default state (no recent activity, all saved): subtle gray "Saved" or hidden
2. Saving: "Saving…" with a tiny spinner
3. Just saved: "Saved" with subtle checkmark, fades after 2 seconds
4. Error: "Save failed — Retry" — red but not screaming; clickable to retry
5. Offline: "Offline — your changes will sync when you reconnect" — yellow/orange
6. Conflict: "Updated elsewhere — Resolve" — orange; opens conflict UI
Constraints:
- Must not move the editor or shift layout (use absolute positioning or fixed-width)
- Must be readable on light + dark mode
- Should not flicker on rapid debounced saves — pause "Saved" state for at least 2s before allowing it to disappear
Build a `<AutosaveStatus state={state} lastSavedAt={ts} />` component for [Tailwind / Radix / shadcn].
"Save Draft" Button (Hybrid Pattern)
For surfaces where users expect explicit control alongside autosave.
I want both autosave AND an explicit "Save Draft" button for [content, e.g. "blog post editor"].
Behavior:
- Autosave runs in the background as usual (debounced 2s)
- "Save Draft" button forces an immediate save and shows toast "Draft saved"
- Click handler sets a "force save" flag that bypasses the debounce
- Disabled state during in-flight save
- Keyboard shortcut Cmd/Ctrl+S triggers the same path
UX rationale: experienced users want explicit saves (especially with `Cmd+S` muscle memory); casual users benefit from autosave. Both should coexist.
Build the hook + button + keyboard binding. Show the integration with `useAutosave`.
Common Pitfalls
Saving on every keystroke without debounce. This generates load on the server, burns battery on mobile, and trips rate limits. Always debounce.
No status indicator. Users can't tell if their work is saved. Even a tiny "Saved" / "Saving…" makes the system trustworthy.
Saving invalid drafts and refusing to load them. If your save endpoint validates strictly, drafts that fail validation can't load. Either accept invalid drafts (and validate on publish), or tell the user immediately what's wrong rather than silently failing.
Forgetting beforeunload / sendBeacon. If the user closes the tab during the debounce window, their last few seconds of work are lost. Use sendBeacon for last-ditch save.
No conflict detection. Two tabs / two devices race; one client silently wins. Add version integer + 409 handling.
localStorage everything. localStorage has a 5MB cap; rich-text drafts with images blow past it fast. Use IndexedDB for anything large.
Saving server-rejected drafts forever. If save consistently fails (401, 403, 500), retrying 100 times in a row burns server capacity. Cap retries; surface the error; let the user choose.
Stale drafts piling up in localStorage. Drafts of deleted resources, drafts from other users on shared devices. Clean up periodically (older than 7 days, or when user logs out).
Multiple-tab autosave race conditions. Tabs race; conflicts cascade. Use BroadcastChannel + leader election.
onChange saving the wrong shape. Especially in rich-text editors, the editor's "current state" can be expensive to serialize. Cache the last-serialized version and diff before saving.
No "your draft was restored" affordance. Silently restoring a draft on page load surprises users who thought their changes were elsewhere. A subtle "Draft restored from 3 minutes ago. [Discard]" lets them confirm.
Saving while the user is mid-IME (Asian language input). Some IME systems trigger input events during composition. Debounce or check compositionstart/compositionend to avoid saving partial characters.
Forgetting to handle 401 (re-auth needed) during autosave. User's session expired; their draft can't save. Surface this clearly: "Session expired — sign in to save your draft."
No "discard draft" option. Users sometimes want to abandon a draft. If autosave creates persistent server-side drafts, a "Discard" button must exist.
See Also
- Form Validation UX
- Optimistic UI Updates
- Multi-Step Forms / Wizards
- Real-Time Collaboration
- Realtime Presence & Collaborative Cursors
- Rich Text Editor Implementation
- Inline Editing Patterns
- Toast Notifications UI
- HTTP Retry & Backoff
- Idempotency Patterns
- Background Jobs & Queue Management
- Empty States, Loading & Error States