The User Wrote 2,000 Words Then Lost Them: Draft Autosave for Vibe Projects
A user's 2,000 words must survive crashes, dead networks, and accidental tab closes. Three defensive layers — debounced input, localStorage safety net, versioned server persistence — plus optimistic-locking conflict UX (keep both, let the user choose), offline replay, and draft lifecycle.

This article is about one thing only: draft autosave for single-user scenarios — a user writes 2,000 words, the browser crashes / network drops / tab closes by accident, and the words survive. Boundaries first:
- vs "Two People Editing the Same Line: Realtime Collaboration": that one covers multi-user collaboration (OT/CRDT algorithms); this one covers single-user draft persistence — debounce, version conflicts, offline recovery. No collaboration algorithms.
- vs the rich-text editor guide: that one covers editor selection and integration; this one covers the persistence layer outside the editor, reusing its editor conclusions.
- vs the concurrency & transactions guide: borrows the version-based optimistic locking idea, but the scenario is draft-conflict UX — no isolation levels.
Lost drafts are among the most trust-destroying incidents in a vibe project: unlike a 500 error that you fix and move on, the user's lost 2,000 words never come back. And AI-generated editor code almost always has just an onChange calling the save API directly — no debounce, no local safety net, no conflict handling. This article gives you three defensive layers and a copy-paste-ready design.
Three defensive layers: every lost-draft incident is a missing layer
| Layer | Protects against | Implementation | Recovery UX |
|---|---|---|---|
| 1. Input debounce (memory) | Hammering the API on every keystroke | Save 800ms after typing stops | Instant, invisible |
| 2. localStorage safety net | Browser crashes, accidental tab closes | Write locally on every debounced trigger | "Restore draft?" prompt on reopen |
| 3. Server persistence | Device switches, cache clears | Async API call after debounce, with version | Pick up writing on any logged-in device |
All three are required; each missing layer loses a different kind of draft:
- Missing layer 1: every keystroke fires a save API call and melts the server — or, to save money, you set the save interval to 30s, and the user closes the tab at second 29. Twenty-nine seconds of words, gone.
- Missing layer 2: browser crashes are the most common draft-loss scenario. No matter how fast server saves are, they can't rescue the 800ms that never got sent.
- Missing layer 3: the user writes half on their phone and wants to continue on the desktop at home — without a server, the draft only lives in the phone's browser.
// Minimal three-layer implementation (pseudocode, framework-agnostic)
let timer = null;
let version = 0; // server version, see section 3
function onInput(content) {
// Layer 1: debounce
clearTimeout(timer);
// Layer 2: sync-write locally on every input (cheap, reliable)
localStorage.setItem('draft:' + docId, JSON.stringify({ content, ts: Date.now() }));
setSaveStatus('editing'); // "Editing…"
timer = setTimeout(() => saveToServer(content), 800);
}
async function saveToServer(content) {
setSaveStatus('saving'); // "Saving…"
// Layer 3: versioned server save
const res = await api.put(`/docs/${docId}/draft`, { content, version });
version = res.version;
localStorage.removeItem('draft:' + docId); // clear local once on server
setSaveStatus('saved'); // "Saved 12:03"
}
Debounce tuning: 300ms or 2s? Depends on content type
Debounce timing isn't a guess — two tiers:
- Short content (titles, comments, single-line inputs): 300–500ms. A pause means intent is clear; save fast, or "I hit publish and it wasn't saved" incidents will find you.
- Long content (articles, code): 800ms–2s. Pauses while writing long-form are thinking, not finishing. 800ms is the sweet spot: no API hammering, no endless "saving" spinner.
Status copy is half the UX: Editing… → Saving… → Saved 12:03. All three states are required — "Saved" must carry a timestamp, or users can't tell which version was saved. AI-generated code often has only a "Saved!" toast with no timestamp, which says nothing.
Version conflicts: the multi-tab editing disaster
A user opens the same draft on the work laptop and the home laptop (or just two tabs), writes in both, and the later save silently overwrites the earlier — last-write-wins, silent word loss. The nastiest draft bug there is, because nothing errors; there are just fewer words.
The fix is version-based optimistic locking — the version field in the code above:
-- drafts table
CREATE TABLE drafts (
doc_id TEXT PRIMARY KEY,
content TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1, -- +1 on every save
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- save with "the version I based this on"
-- UPDATE drafts SET content = $1, version = version + 1
-- WHERE doc_id = $2 AND version = $3
-- affected rows = 0 → someone saved first → conflict!
On conflict, never silently overwrite — "keep both, let the user choose":
- Show both the server's latest and the local version (diff highlighting is better; side-by-side works without it);
- Three buttons: keep mine / use server's / merge manually;
- Log every resolution (who resolved which conflict when).
Note the difference from realtime collaboration: collaborative editing uses OT/CRDT auto-merge because that's the "simultaneously online" scenario; draft conflicts are the "sequentially offline" scenario, where auto-merge's risk far outweighs its benefit — letting the user choose is the only correct UX.
Offline & flaky networks: mandatory for mobile, don't skip on desktop
Users keep writing when the network drops (subway, elevator, café Wi-Fi hiccups) — your save pipeline can't break. The design: a local queue + ordered replay after reconnect:
// Offline queue: failed saves enqueue, replay in order on reconnect
const pendingQueue = [];
async function saveToServer(content) {
try {
const res = await api.put(`/docs/${docId}/draft`, { content, version });
version = res.version;
} catch (e) {
// Network failure: enqueue; localStorage already has the safety net,
// no words are lost
pendingQueue.push({ content, version, ts: Date.now() });
setSaveStatus('offline'); // "Offline — will sync when reconnected"
}
}
window.addEventListener('online', async () => {
// replay in chronological order; versions may be stale → conflict flow
for (const item of pendingQueue.splice(0)) {
await saveToServer(item.content);
}
});
Three details:
- Status must be honest: "Offline — will sync when reconnected" and "Saved" are two different states that must never be conflated. Showing "Saved" while offline is lying to the user.
- Replay can trigger conflicts: while offline, another device may have saved a newer version. Replay carries the old
version; the server check catches the staleness — go straight to the "keep both" flow from the previous section, don't attempt auto-merge. - Cap the queue: keep e.g. the latest 50. An unbounded queue plus long offline time equals memory explosion. When over the cap, drop the oldest — the full snapshot in localStorage still holds every word.
Draft lifecycle: birth, retention, graceful end
Drafts don't end at writing; they have a lifecycle with three design-time questions:
- How long to retain? Recommendation: unpublished drafts auto-clean after 30 days (borrowing the soft-delete & trash pattern: mark deleted first, hard-delete after 7 days, giving users a regret window). A published document's draft gets deleted immediately on successful publish — keeping it is garbage.
- Schema reuse: draft and published-content tables should share the same
contentstructure. AI-generated code often uses one format for drafts and another for published content, needing a "conversion" at publish time — conversion is a bug farm. - Quotas: a solo team doesn't need limits on day one, but keep a per-
user_idcounter column in the schema. When someone starts using drafts as cloud storage, you'll have the lever.
Rich-text / image draft edge cases
Plain-text drafts are easy; image-bearing drafts have two traps:
- Half-uploaded images: the user pastes a 5MB image, closes the tab at 30% upload. On draft restore, the image is an "uploading" placeholder — which must support resume or re-upload, not a broken image. Implementation: the placeholder stores the local blob's reference ID; on restore, check upload state and re-trigger incomplete uploads.
- Blob URL promotion: pasted images get temporary
blob:https://...URLs that die with the tab. On draft save, the "uploaded → permanent URL" promotion must complete; drafts store only permanent URLs. Reuse the upload pipeline from file storage — don't build a second one.
Solo launch checklist
Three mandatory test scenarios — don't ship failing any:
- ☐ Offline test: write 500 words → go offline → write 500 more → reconnect → confirm all 1,000 words present, and status copy went "Offline → Saving → Saved"
- ☐ Two-tab conflict test: tab A writes "111" and saves; tab B (stale version) writes "222" and saves → confirm the "keep both" chooser appears instead of silent overwrite
- ☐ Crash recovery test: write 500 words → kill the browser process → reopen → confirm "Restore draft?" appears with content intact
- ☐ Debounce tiered by content type (short 300–500ms / long 800ms–2s); "Saved" carries a timestamp
- ☐ 30-day draft retention + delete-after-publish lifecycle implemented
Draft autosave is the kind of feature nobody praises when it works and everybody curses when it fails. But it measures exactly how much your product respects users' time — every word a user writes deserves to be taken seriously. Two thousand words of trust, once lost, is hard to win back.
Comments (0)
Related articles

AI-built dark mode fails three predictable ways: hardcoded colors, white flashes on toggle, system preference fighting explicit choice. The fix: semantic CSS variables as the single source of truth, Tailwind v4 @custom-variant wiring, a system/light/dark state machine, an inline <head> script killing SSR first-paint flash (Next.js specifics, quantified acceptance), plus images, canvas charts, iframe embeds, WCAG AA contrast sampling — and a 5-step migration SOP with an AI rewrite prompt.

A prompt is a suggestion, not a constraint. Four defensive layers for reliable AI structured output: native structured outputs (OpenAI vs Anthropic, verified against docs), zod validation with the strict/passthrough tradeoff, repair retries that feed raw errors back, and degraded fallbacks chosen by cost.

Feature flags decouple deploy from release: code ships first, features turn on later. Four flag types and a selection matrix, a one-table minimal implementation, the fallback iron rule (new features fail-closed, kill-switches freeze), flag-rot governance, and how flags pair with canary releases.