Feature spec
BeginnerCategory
personalization-ux
Build with AI
1–2 hours with Lovable or V0
Custom build
2–4 days custom dev
Running cost
$0–$25/mo depending on Supabase plan
Works on
Everything it takes to ship Auto-Save — parts, prompts, and real costs.
Auto-save needs three layers: a dirty-state tracker that detects changes, a debounced save (1500ms after last keystroke) plus a 30-second interval fallback, and a four-state status indicator (idle, saving, saved, error). The trickiest parts are the useEffect cleanup to prevent memory leaks and a beforeunload handler for emergency localStorage backup. With V0 or Lovable you can ship this in 1–2 hours for $0–$25/month depending on Supabase tier.
What Auto-Save Actually Is — and What Makes It Hard
Auto-save sounds like a simple setInterval that writes to the database every 30 seconds. In practice, three subtleties separate a production implementation from a broken one. First, saving on every keystroke hammers the database and creates a terrible UX — the correct pattern debounces 1500ms after the last keystroke, with the 30-second interval as a fallback for users who pause mid-thought. Second, the setInterval must be cleaned up when the component unmounts or the app leaks memory and writes to Supabase after the user has navigated away. Third, two browser tabs editing the same document will silently overwrite each other without conflict detection on updated_at. Each of these failure modes ships in AI-generated code unless explicitly specified in the prompt.
What users consider table stakes in 2026
- Silent save every 30 seconds with a 'Saved X seconds ago' timestamp that updates in real time
- Manual save shortcut (Cmd/Ctrl+S) that overrides any debounce and triggers an immediate save
- Four distinct visual states: idle (no changes), saving (spinner), saved (green checkmark + timestamp), error (red warning with retry button)
- Conflict resolution indicator when the same document is edited from two tabs — surface both versions rather than silently overwriting
- Emergency draft restoration on page reload if the last save failed — users never lose more than one typing burst
- Undo/redo history preserved across saves without interrupting the editing experience
Anatomy of the Feature
Seven components make up production auto-save. AI tools generate the interval and the status indicator reliably. The conflict detection, cleanup, and dirty-state tracking need explicit prompting to avoid the bugs that surface in production.
Dirty State Tracker
UIA React useRef boolean (isDirty) that flips to true on any form change event. Using a ref rather than state prevents the change detection itself from triggering a re-render. The useDebounce hook from usehooks-ts watches the document content value and triggers the save function 1500ms after the last change — the ref is checked at save time to avoid unnecessary writes when content has not changed.
Note: Avoid putting the entire document object in a useEffect dependency array — objects are recreated by reference on each render, causing the effect to fire on every render rather than on actual content changes.
Auto-Save Interval
UIA setInterval(flush, 30000) backup that fires every 30 seconds regardless of debounce state. This catches users who type for more than 30 seconds without pausing. The interval is set up in a useEffect and the cleanup function returns () => clearInterval(id) — this is the most commonly missed line in AI-generated code. Uses Date.now() to track lastSavedAt for the status timestamp.
Note: React StrictMode in development double-invokes useEffect to catch missing cleanup. If you see the interval fire twice in dev but not in production, the cleanup is working correctly. If the interval fires after unmount in both environments, the cleanup is missing.
Save Status Indicator
UIFour states rendered via conditional className on a single status component. Idle: gray dot. Saving: shadcn/ui Loader2 spinning icon. Saved: green CheckCircle icon with 'Saved X ago' text using date-fns formatDistanceToNow(). Error: red AlertCircle icon with the error message and a Retry button that triggers an immediate save attempt. The status persists between saves so users see 'Saved 2 minutes ago' during quiet editing periods.
Note: Format the saved timestamp as a relative string ('Saved 30 seconds ago', 'Saved 2 minutes ago') rather than a clock time — relative strings communicate recency better for an auto-save UX.
Conflict Detection
BackendThe documents table includes an updated_at timestamptz column maintained by a Supabase trigger. Before writing, the save function checks whether the server's updated_at is newer than the local lastFetchedAt timestamp. If it is, another session (or another user in collaborative mode) has saved changes since this session loaded the document. Surface a conflict modal showing both the local version and the server version with options to keep one or merge.
Note: Implement conflict detection as an UPSERT with a WHERE updated_at = lastFetchedAt condition. If zero rows are affected, the WHERE clause filtered out the update — that is the conflict signal.
Draft Persistence
DataA beforeunload event listener writes the current unsaved content to localStorage synchronously as an emergency backup. On component mount, compare the localStorage draft timestamp against the server document's updated_at — load whichever is newer. The Supabase table stores the primary copy; localStorage is the disaster-recovery fallback only.
Note: Clear the localStorage draft on every successful Supabase save response — if you don't, the stale draft will be restored on the next page load even though the save succeeded.
Keyboard Shortcut Handler
UIA useEffect attaches a keydown listener to window for (e.metaKey || e.ctrlKey) && e.key === 's'. The first line of the handler is e.preventDefault() to block the browser's native 'Save Page As' dialog. This triggers an immediate save, bypassing both the debounce delay and the 30-second interval.
Note: Attach the listener to window, not document.body. If attached to a child element, the event may not reach the handler when focus is elsewhere on the page.
Optimistic Update Layer
BackendReact Query useMutation with an onMutate callback that optimistically sets the status to 'saved' before the network request resolves. onError rolls back to 'error' status with the previous content restored. onSettled calls queryClient.invalidateQueries(['document', id]) to refresh from the server after the mutation completes, ensuring the local state is accurate.
Note: Optimistic updates make the status indicator feel responsive — users see 'Saved' the moment they trigger the save, not 200–500ms later when the Supabase round-trip completes.
The data model
Auto-save requires columns on your existing content table plus optional draft tracking. Run this SQL in the Supabase SQL Editor to add the necessary columns and set up the updated_at trigger:
1-- Add auto-save columns to your existing documents table2-- (Adjust 'documents' to match your actual table name)3alter table public.documents4 add column if not exists draft_content jsonb,5 add column if not exists published_content jsonb,6 add column if not exists auto_saved_at timestamptz,7 add column if not exists version int not null default 1;89-- Trigger to auto-update updated_at on every row change10create or replace function public.handle_updated_at()11returns trigger language plpgsql as $$12begin13 new.updated_at = now();14 return new;15end;16$$;1718create trigger set_documents_updated_at19 before update on public.documents20 for each row execute procedure public.handle_updated_at();2122-- If you don't have a documents table yet, create one:23create table if not exists public.documents (24 id uuid primary key default gen_random_uuid(),25 author_id uuid references auth.users(id) on delete cascade not null,26 title text not null default 'Untitled',27 draft_content jsonb,28 published_content jsonb,29 auto_saved_at timestamptz,30 version int not null default 1,31 created_at timestamptz not null default now(),32 updated_at timestamptz not null default now()33);3435alter table public.documents enable row level security;3637create policy "Authors can view own documents"38 on public.documents for select39 to authenticated40 using (auth.uid() = author_id);4142create policy "Authors can insert own documents"43 on public.documents for insert44 to authenticated45 with check (auth.uid() = author_id);4647create policy "Authors can update own documents"48 on public.documents for update49 to authenticated50 using (auth.uid() = author_id)51 with check (auth.uid() = author_id);5253create index documents_author_updated_idx54 on public.documents (author_id, updated_at desc);Heads up: The draft_content and published_content separation lets you build a publish workflow where auto-save writes only to draft_content while published_content stays stable for readers. If you only need auto-save without a publish workflow, a single content jsonb column is sufficient.
Build it — pick your path
Each tool fits this feature differently. Switch tabs to compare fit, time, the exact steps, and a copy-paste prompt.
V0 generates polished save status UI with shadcn/ui components natively. The Cmd+S keyboard handler and debounced save with usehooks-ts are patterns V0 handles reliably. Best choice for a Next.js app with an existing Supabase project.
Step by step
- 1Paste the prompt below in V0 — it generates a complete auto-save hook and status indicator component
- 2In the Vars panel, add NEXT_PUBLIC_SUPABASE_URL and NEXT_PUBLIC_SUPABASE_ANON_KEY
- 3Run the SQL schema from this page in your Supabase SQL Editor
- 4Test the keyboard shortcut: Cmd+S should trigger an immediate save and the browser should not open a 'Save As' dialog
- 5Test the conflict: open the document in two tabs, edit both, save from tab 2, then save from tab 1 — a conflict warning should appear
Create a Next.js auto-save feature as a reusable hook and status component. Mark the parent component 'use client'. Hook: useAutoSave({ documentId, content, supabaseClient }) — returns { status, lastSavedAt, save }. Inside the hook: 1) useRef isDirty — set true on content change, false after save. 2) useDebounce(content, 1500) from usehooks-ts — when debounced value changes and isDirty is true, call save(). 3) useEffect with setInterval(save, 30000) — MUST return () => clearInterval(id). 4) useEffect with window keydown listener for (e.metaKey||e.ctrlKey) && e.key==='s' — e.preventDefault() first, then save(). 5) useEffect with window beforeunload listener — write content to localStorage as JSON with { content, savedAt: Date.now() } under key 'draft_' + documentId; read on mount, compare savedAt vs server updated_at, restore if newer; clear localStorage on successful save. 6) save() function: set status to 'saving'; UPSERT to Supabase documents table (draft_content, auto_saved_at = now()); on success set status to 'saved', update lastSavedAt; on error set status to 'error'. Conflict check: before upsert, fetch current updated_at from server; if server updated_at > lastFetchedAt, set status to 'conflict' and return without saving. Status component: shadcn/ui Badge — gray dot for idle, Loader2 for saving, CheckCircle + date-fns formatDistanceToNow(lastSavedAt) for saved, AlertCircle + Retry button for error, AlertTriangle for conflict. Wrap all localStorage reads in useEffect (SSR safety).Where this path bites
- V0 localStorage reads outside useEffect cause Next.js hydration warnings — ensure all localStorage access is inside useEffect or a 'use client' component that V0 has correctly marked
- V0 does not provision the Supabase documents table — run the SQL from this page in your Supabase SQL Editor before testing
Third-party services you'll need
Auto-save requires only open-source libraries and Supabase — no paid third-party APIs:
| Service | What it does | Free tier | Paid from |
|---|---|---|---|
| usehooks-ts | useDebounce hook for 1500ms delay, useLocalStorage and useEventListener for draft persistence | Open-source, zero cost | Free |
| date-fns | formatDistanceToNow() for the 'Saved X minutes ago' relative timestamp in the status indicator | Open-source, zero cost | Free |
| Supabase | Document persistence, draft_content storage, updated_at conflict detection | Free tier: 500MB DB, 2GB bandwidth | Pro $25/mo (8GB DB, 250GB bandwidth) |
Swipe the table sideways to see pricing.
What it costs to run
Drag through the tiers to see how your monthly bill scales with users — no surprises later.
Estimated monthly running cost
Supabase free tier. Auto-save writes are small JSON payloads. At 100 users making ~50 saves per session per day, the write volume is well within the free tier's database limits.
Estimates use verified 2026 pricing and assume typical usage per user. Your real bill depends on activity, storage, and third-party plans.
What breaks when AI tools build this
The failures people actually hit on their first build — the symptom, why it happens, and the exact fix.
Memory leak after navigating between pages
Symptom: When a user navigates away from the editor, React unmounts the component. If the setInterval is not cleared in the useEffect cleanup function, the interval continues running in the background, calling Supabase with stale content and attempting to update React state on an unmounted component. In development with React StrictMode, this appears as a console warning. In production, it silently overwrites documents with stale data.
Fix: Return a cleanup function from every useEffect that sets up an interval: return () => clearInterval(intervalId). React calls this function when the component unmounts, stopping the interval. Also clear the beforeunload event listener in the same cleanup function.
Two browser tabs silently overwrite each other
Symptom: Both tabs save independently to the same document row. The second tab to save wins, destroying the first tab's changes. This is 'last writer wins' — a silent data loss bug that users discover only after losing work. It is especially damaging in documents where users have different sessions open on desktop and mobile.
Fix: Before each UPSERT, fetch the current updated_at from the server and compare it against the timestamp of the last known server state (lastFetchedAt). If the server's timestamp is newer, another session has written to this document — surface a conflict modal showing both versions. Let the user choose which to keep before proceeding.
Stale draft restored from localStorage after a successful save
Symptom: The beforeunload handler writes to localStorage on every page unload. If the user closes the tab after a successful save, the last auto-save content is in both Supabase (the authoritative record) and localStorage (the emergency backup). On the next visit, the app reads localStorage, finds a timestamp, and restores it — even though the server already has this content. This causes a confusing stale-data restoration even when nothing failed.
Fix: Call localStorage.removeItem('draft_' + documentId) immediately after every successful Supabase save response. On mount, only restore from localStorage if its timestamp is newer than the server's updated_at — not just if a localStorage entry exists.
Cmd+S opens the browser 'Save Page As' dialog instead of triggering app save
Symptom: The browser's default keyboard shortcut for Cmd/Ctrl+S is 'Save Page As'. If the keydown listener calls e.preventDefault() after some asynchronous code, or if the listener is attached to a child element rather than window, the browser default fires before the app handler runs.
Fix: Make e.preventDefault() the absolute first line of the keyboard handler function — before any conditional checks or async operations. Attach the listener to window, not document.body or a specific element. Verify with DevTools: the 'Save Page As' dialog should never appear when the editor is focused.
Auto-save fires on every render and hammers Supabase
Symptom: If the document content is an object and that object reference is placed in a useEffect dependency array, React will re-run the effect on every render because objects are compared by reference. A component that re-renders 60 times per second will fire 60 Supabase UPSERT calls per second per user, hitting rate limits almost immediately and generating a large database bill.
Fix: Track dirty state with a separate isDirty useRef boolean. Only call save() when isDirty is true. Reset isDirty to false immediately after save() is called. Do not put the entire document object in the useEffect dependency array — watch a stable primitive value like a content hash or character count instead.
Best practices
Debounce 1500ms after the last keystroke AND add a 30-second interval fallback — the debounce alone misses users who type for long unbroken stretches
Return () => clearInterval(id) from every useEffect that creates an interval — this is the single most commonly missing line in AI-generated auto-save code
Store and compare updated_at timestamps to detect conflicts before every write — silent last-writer-wins data loss is worse than surfacing a conflict modal
Clear the localStorage draft key on every successful save response — stale drafts that restore after a successful save are as confusing as losing work
Show a relative timestamp ('Saved 2 minutes ago') not an absolute clock time — relative timestamps communicate recency and freshness more intuitively for save indicators
Increase the debounce delay at scale — 1500ms is right for a single user, but at 10K active users consider 3000ms to halve write volume without a perceptible UX difference
Test the 'no internet' case by disabling your network mid-edit — the error state with a Retry button must appear, and the localStorage draft must be intact on reload
When You Need Custom Development
Single-user auto-save is well within what AI tools generate reliably. Two product requirements push past that boundary:
- Real-time collaborative editing with multiple simultaneous users — requires Yjs CRDT (Conflict-free Replicated Data Type), a WebSocket server (Liveblocks, PartyKit, or self-hosted y-websocket), and Operational Transformation; this is a fundamentally different architecture from single-user auto-save
- Versioned document history with named snapshots and the ability to diff and restore any point in time — requires an append-only versions table, a diff algorithm (e.g. diff-match-patch), and a snapshot UI that AI tools cannot generate in a single prompt
- Offline-first architecture where auto-save works without internet and queues changes for sync on reconnect — requires IndexedDB via Dexie.js and a Service Worker for background sync
- Compliance requirement for a tamper-proof audit trail of every save event with user attribution and server-side timestamp signing
RapidDev builds these features for production
Real auth, database, and payments — fixed price, delivered in weeks. You own 100% of the code.
Frequently asked questions
How often should auto-save fire to avoid annoying users or overloading the database?
The right pattern is debounce plus interval, not one or the other. Debounce 1500ms after the last keystroke catches rapid typing sessions quickly. The 30-second interval catches long pauses. For database-conscious apps with 10K+ users, increase debounce to 3000ms — users cannot perceive the difference between 1.5 and 3 seconds, but it halves your write volume.
What happens to unsaved work if the user's internet drops?
The beforeunload handler writes to localStorage synchronously — this works even without internet because it is a local browser operation. On reconnect, the next auto-save tick or the next Retry button click will attempt the Supabase UPSERT. For a more robust offline experience, check navigator.onLine before saving and queue the save for the 'online' event if the connection is down.
Can auto-save work on mobile browsers?
Yes, with one caveat: the beforeunload event is unreliable on mobile Safari and Chrome for Android because mobile browsers can terminate tabs without firing beforeunload. The more reliable pattern for mobile is the visibilitychange event — when the page becomes hidden (tab switch or home button), treat it as an unload and write to localStorage immediately.
How do I show users which version is the latest versus a draft?
Use separate database columns for draft_content and published_content. Auto-save writes to draft_content only. Publishing copies draft_content to published_content and increments a version counter. The status indicator shows 'Draft saved X minutes ago' versus 'Published version X'. Users can see they have unpublished changes when draft_content differs from published_content.
Can I add version history so users can go back to earlier saves?
Yes — create a document_versions table (id, document_id, content jsonb, saved_at timestamptz, version int) and insert a row on each auto-save or on publish. Cap the history at the last 50 versions to avoid unbounded growth. A version history UI is a separate feature that reads from this table, but the auto-save foundation already creates the data.
Does auto-save work in rich text editors like TipTap or Slate?
Yes. TipTap and Slate both expose onChange callbacks that fire when the editor content changes. Connect the dirty state tracker to the editor's onChange. To serialize the rich text for storage, use TipTap's JSON output (editor.getJSON()) or Slate's standard serialize function. Store the result in the draft_content jsonb column. On load, pass the stored JSON back to the editor's initialContent or value prop.
How do I prevent auto-save from saving incomplete or invalid form data?
Run zod validation before each save and only write to Supabase if the validation passes. On failure, set status to 'idle' (do not show an error for incomplete mid-edit state) and schedule a retry after the next debounce. Alternatively, save raw draft content always but run validation only on explicit publish — this is the Google Docs pattern where drafts can be 'broken' but published versions are always valid.
What is the difference between auto-save and autofill?
Auto-save writes form or editor content to a database as the user types, so they can recover their work later. Autofill is the browser's built-in feature that pre-populates form fields with previously entered values from browser memory. They are completely separate mechanisms. Auto-save is something you build; autofill is handled by the browser and can be disabled with autocomplete='off' on specific fields if it conflicts with your form.
Need this feature production-ready?
RapidDev builds auto-save into real apps — auth, database, payments — at $13K–$25K.
Book a free consultation30-min call. No commitment.