Implement a document auto-save system with dual-layer debouncing that balances responsive undo/redo with batched database writes. Use when the user asks to "add auto-save", "implement debounced save", "add undo redo with auto-save", "build document auto-save architecture", "implement dual debounce save", "add editor auto-save", or wants responsive editing with efficient persistence.
From recipesnpx claudepluginhub ichabodcole/project-docs-scaffold-template --plugin recipesThis skill uses the workspace's default tool permissions.
Provides UI/UX resources: 50+ styles, color palettes, font pairings, guidelines, charts for web/mobile across React, Next.js, Vue, Svelte, Tailwind, React Native, Flutter. Aids planning, building, reviewing interfaces.
Fetches up-to-date documentation from Context7 for libraries and frameworks like React, Next.js, Prisma. Use for setup questions, API references, and code examples.
Calculates TAM/SAM/SOM using top-down, bottom-up, and value theory methodologies for market sizing, revenue estimation, and startup validation.
Implement a document auto-save system that uses two debounce layers to solve two different problems simultaneously: grouping rapid keystrokes into meaningful undo/redo commands (user experience) and batching database writes for performance (system efficiency). A single debounce layer forces a compromise between these goals; two layers let you optimize each independently.
This recipe is technology-agnostic at the architecture level. The concepts, data flow, and service API work with any editor component, any database (SQL, NoSQL, local SQLite, cloud Postgres), and any frontend framework (React, Vue, SwiftUI, etc.).
Every keystroke passes through two independent debounce gates before reaching the database. Each gate serves a different purpose and can be tuned independently.
User types characters
|
v
+---------------------------------------+
| Layer 1: Undo Batching |
| (e.g., 300ms debounce) |
| |
| Groups rapid keystrokes into a single |
| undo command. Resets timer on each |
| new keystroke. |
| |
| Output: "undo commit" (batch of |
| changes pushed to undo stack) |
+------------------+--------------------+
|
| content-change event
v
+---------------------------------------+
| Layer 2: Database Persistence |
| (e.g., 300ms debounce) |
| |
| Coalesces undo commits before writing |
| to database. Resets timer on each |
| new undo commit. |
| |
| Output: database write |
+------------------+--------------------+
|
v
+------------------+
| Database |
| (SQLite, etc.) |
+------------------+
Layer 1 (Undo Batching): Without this, pressing undo would remove a single character. Users expect undo to remove words or phrases. Layer 1 groups keystrokes during a typing burst (defined by the debounce window) into a single undo command.
Layer 2 (Database Persistence): Without this, every undo commit would trigger a database write. Layer 2 coalesces multiple undo commits that happen in quick succession before hitting the database.
Result with 300ms on each layer: During sustained typing, undo commits fire every ~300ms (whenever the user pauses), but database writes only happen ~300ms after the last undo commit. Effective write interval during typing: ~600ms. When the user pauses, both layers flush within ~600ms total.
Why not a single longer debounce? A single 600ms debounce would give you the same write frequency, but undo would group 600ms of typing into one step. That is too coarse - users lose fine-grained undo. Two 300ms layers give you 300ms undo granularity with 600ms write frequency. You can tune each independently.
From keystroke to database, step by step:
1. User types a character
2. Editor input handler detects content change
3. Editor calls UndoManager.addChange(diff, beforeState)
4. Editor calls UndoManager.scheduleCommit(afterState, DEBOUNCE_MS)
5. --- Layer 1 debounce: timer resets on each keystroke ---
6. DEBOUNCE_MS elapses with no new input
7. UndoManager.commitBatch() fires:
- Pushes changes to undo stack
- Emits "content-change" event with new content
8. Content-change handler receives event
9. Handler marks document as dirty
10. Handler calls debouncedSave(content)
11. --- Layer 2 debounce: timer resets on each undo commit ---
12. DEBOUNCE_MS elapses with no new undo commits
13. debouncedSave fires:
- Calls updateVersion(activeVersionId, content)
- Marks document as clean on success
14. Database transaction:
- Updates version record content
- Updates document content mirror
With 300ms debounce on both layers:
0ms: Type "H"
50ms: Type "e" -> Layer 1 timer resets to 350ms
100ms: Type "l" -> Layer 1 timer resets to 400ms
150ms: Type "l" -> Layer 1 timer resets to 450ms
200ms: Type "o" -> Layer 1 timer resets to 500ms
500ms: Layer 1 fires -> undo commit "Hello", content-change event
Layer 2 timer starts (fires at 800ms)
600ms: Type " " -> Layer 1 timer resets to 900ms
700ms: Type "w" -> Layer 1 timer resets to 1000ms
800ms: Layer 2 fires -> DB write "Hello" (the last content-change)
Note: "w" is not yet in the DB (Layer 1 hasn't committed it)
...
Key insight: The user is always editing the in-memory content. The database is always slightly behind. The dirty state flag tells the UI whether unsaved changes exist.
The undo manager handles change batching and undo/redo stacks. One instance per document, created lazily on first edit.
API surface:
UndoManager
addChange(change, beforeState) // Buffer a text change
scheduleCommit(afterState, delayMs) // Start/restart debounce timer
commitBatch(afterState) // Flush immediately
cancelScheduledCommit() // Cancel pending timer
hasPendingBatch() -> boolean // Check for uncommitted changes
undo() -> UndoCommand | null // Pop from undo stack
redo() -> UndoCommand | null // Pop from redo stack
clear() // Reset all state
Internal state:
undoStack: UndoCommand[] // Committed undo commands
redoStack: UndoCommand[] // Commands available for redo
currentBatch: TextChange[] // Uncommitted changes in progress
batchTimer: Timer | null // Layer 1 debounce timer
maxStackSize: number // FIFO limit on stacks (e.g., 100)
Debounce implementation (pseudocode):
function scheduleCommit(afterState, delayMs):
if currentBatch is empty:
return // Nothing to commit
cancelScheduledCommit() // Cancel any existing timer
batchTimer = setTimeout(delayMs):
commitBatch(afterState)
function commitBatch(afterState):
if currentBatch is empty:
return
command = {
id: generateId(),
changes: clone(currentBatch),
beforeState: savedBeforeState,
afterState: afterState,
timestamp: now()
}
undoStack.push(command)
enforceMaxStackSize() // FIFO: remove oldest if over limit
resetBatch() // Clear currentBatch and timer
Per-document isolation: Each document gets its own UndoManager instance. Switching documents does not carry undo history across. Instances are stored in a Map keyed by document ID and created lazily on first access.
The editor component captures user input and drives Layer 1.
Responsibilities:
UndoManager.addChange() and scheduleCommit() on each changeImmediate commit pattern: Some discrete operations (like moving a line up/down) should bypass debouncing and commit immediately. These are intentional, atomic actions that users expect as individual undo steps:
function handleInput(isLineMovement):
change = calculateDiff(previousContent, currentContent)
if change:
undoManager.addChange(change, beforeState)
if isLineMovement:
undoManager.commitBatch(afterState) // Immediate
else:
undoManager.scheduleCommit(afterState, DEBOUNCE_MS) // Debounced
A debounced function that writes content to the database. Created separately from the undo manager.
Pseudocode:
// Create debounced save function
debouncedSave = debounce(async (content):
if no current document: return
await versionService.updateVersion(activeVersionId, content)
markDocumentClean(documentId)
, DEBOUNCE_MS)
// Content change handler (called when Layer 1 emits content-change)
function handleContentChange(newContent):
if newContent == savedContent:
markDocumentClean(documentId)
else:
markDocumentDirty(documentId)
debouncedSave(newContent)
Dynamic interval updates: If the auto-save interval is user-configurable, recreate the debounced save handler when the setting changes:
watch(autoSaveInterval, (newDelay):
debouncedSave = debounce(saveFunction, newDelay)
)
Note: Existing pending saves use the OLD interval until they fire. New changes use the NEW interval immediately. There is a brief period where both intervals are "active."
Tracks which documents have unsaved changes. Simple Set-based implementation.
Pattern:
dirtyDocuments: Set<string>
function markDocumentDirty(id):
dirtyDocuments.add(id)
function markDocumentClean(id):
dirtyDocuments.delete(id)
function isDocumentDirty(id) -> boolean:
return dirtyDocuments.has(id)
Why asymmetric timing? Marking dirty immediately and clean only on success prevents the UI from showing "saved" while a write is still pending or could fail. This is the safe default.
The database operation called by Layer 2. This updates the active version in-place and maintains the content mirror.
Logic:
function updateVersion(versionId, content):
Transaction:
1. Update document_versions.content WHERE id = versionId
2. Find the document that owns this version
3. If this version IS the active version:
Update documents.content = content (maintain mirror)
4. Update documents.updatedAt = now()
Critical invariant: documents.content MUST always equal the active
version's content. Every code path that changes content must maintain this
mirror. See the Document Versioning recipe for full details on this pattern.
Both debounce layers introduce a window where changes exist in memory but not in the database. If the app closes during this window, changes are lost. The flush-on-exit pattern ensures pending saves complete before the app shuts down.
Every transition out of the editing context must flush both layers:
| Trigger | What to Flush | Platform |
|---|---|---|
| Document switch | Layer 1 (undo batch) | All |
| Editor unmount | Layer 1 + Layer 2 | All |
| App close / quit | Layer 1 + Layer 2 | Desktop |
| App goes to background | Layer 2 | Mobile |
| Before undo/redo | Layer 1 only | All |
| Before AI operation | Layer 1 + Layer 2 | All |
// In the main process or app lifecycle handler:
onBeforeQuit:
// Signal renderer to flush pending saves
// Wait for flush confirmation before allowing quit
// Use IPC to coordinate between processes if needed
onWindowClose:
// Same pattern - flush before allowing close
// Listen for app state changes
onAppStateChange(nextState):
if nextState == "background" or nextState == "inactive":
debouncedSave.flush() // Force immediate execution of pending save
This is especially important on mobile where the OS may terminate backgrounded apps without warning.
// When switching from Document A to Document B:
onDocumentSwitch(oldDocId, newDocId):
// 1. Flush Layer 1 for old document
oldUndoManager = getUndoManager(oldDocId)
if oldUndoManager and oldUndoManager.hasPendingBatch():
oldUndoManager.commitBatch(currentEditorState)
// 2. Layer 2 flush happens automatically because commitBatch
// triggers content-change, which triggers debouncedSave
// 3. Reset editor state for new document
resetPreviousContent(newDocContent)
function handleUndo():
// Must flush pending batch first, otherwise undo would operate
// on an incomplete batch
if undoManager.hasPendingBatch():
undoManager.commitBatch(currentState)
else:
undoManager.cancelScheduledCommit()
command = undoManager.undo()
if command:
applyInverseChanges(content, command.changes)
Auto-save and document versioning are separate systems that must coordinate carefully.
Auto-save calls updateVersion(activeVersionId, content), which modifies the
active version's content in-place. It does NOT call createVersion(). Creating
a version on every keystroke (or every debounce fire) would produce thousands of
versions and make version history useless.
| Operation | Creates Version? | Modifies Active Version? |
|---|---|---|
| Auto-save (typing) | No | Yes (in-place update) |
| Manual "Save Version" | Yes | Yes (new becomes active) |
| AI operation (auto-version on) | Yes | Yes (new becomes active) |
The documents.content field mirrors the active version's content for query
performance (list documents without joining versions). Auto-save must update
both document_versions.content AND documents.content atomically.
See the Document Versioning recipe for full details on the content mirror pattern.
When a user switches to a different version, the undo stack must be cleared. The undo history belongs to the editing session of the previous version. Carrying it across versions would cause confusing behavior.
function switchVersion(documentId, versionId):
// ... switch active version in database ...
clearUndoHistory(documentId)
markDocumentClean(documentId)
Switching documents while a save is pending can cause stale content writes. The flush-on-switch pattern mitigates this, but there is a subtle window:
1. User edits Document A
2. Layer 1 fires -> content-change for Doc A
3. User immediately switches to Document B (before Layer 2 fires)
4. Layer 2 fires with Doc A's content
Is the save applied to the correct document?
Solution: The debounced save function must capture the document ID at the time of the content change, not at the time of execution. Alternatively, flush Layer 2 synchronously on document switch.
If undo fires between Layer 1 and Layer 2, the undo modifies in-memory content but the pending Layer 2 save still has the pre-undo content. This is handled because undo triggers a new content-change event, which resets Layer 2's timer with the post-undo content.
If the user changes the auto-save interval while a save is pending, the old timer continues with the old interval. The new interval applies to the next content change. This brief inconsistency is harmless.
The system should handle saving empty content. An empty document is still a valid document. Do not skip saves when content is empty string.
For documents over ~100KB, the diff calculation in Layer 1 may take measurable time (>1ms). If this becomes a problem:
| Setting | Type | Default | Range | Purpose |
|---|---|---|---|---|
autoSaveInterval | number | 300ms | 100-5000ms | Debounce delay for both layers |
undoStackLimit | number | 100 | 10-500 | Max undo commands per document |
Why both layers share one interval: Simplifies the settings UI. Advanced users could benefit from separate tuning (e.g., 200ms undo batch, 500ms DB write), but the complexity is rarely worth it. One setting that controls both is the pragmatic default.
Platform defaults may differ: Mobile apps may use a slightly longer default (e.g., 500ms) to reduce battery impact from frequent writes, while desktop apps use 300ms for a snappier feel.
TextChange, UndoCommand, EditorStateValidate: Type rapidly, press undo - should remove a word/phrase, not single characters. Press redo - should restore. Switch documents - undo history should be isolated per document.
documents.content stays in sync)Validate: Type rapidly, check database - should see writes every ~600ms (sum of both debounce intervals), not on every keystroke. Content in database should match editor content after debounce settles.
Validate: Edit a document - dirty indicator appears. Wait for auto-save - indicator clears. Edit and immediately undo back to saved content - indicator should clear.
Validate: Edit a document, immediately switch to another - no data loss. Edit a document, close the app - changes are saved. Edit a document, background the app (mobile) - changes are saved.
Validate: Change auto-save interval in settings - new interval takes effect on next edit. Set interval to maximum - undo batches are larger, DB writes less frequent.
Plain textarea / ContentEditable: Listen to input and beforeinput
events. Use beforeinput to capture cursor state before the DOM mutation.
CodeMirror / ProseMirror / TipTap: These editors have built-in change tracking. You may not need Layer 1 at all if the editor provides good undo batching. Layer 2 (debounced persistence) is still needed.
React Native TextInput: Listen to onChangeText. Cursor state is available
via onSelectionChange. Note: beforeinput equivalent does not exist in React
Native; capture previous content in a ref.
lodash/debounce, VueUse useDebounceFn: Common choices. Ensure the utility
supports .flush() for immediate execution (needed for flush-on-exit).
Custom setTimeout: Simple and dependency-free. The pseudocode in this recipe
uses this approach. Must implement .flush() manually.
RxJS debounceTime: Works well in Angular/RxJS codebases. Wire Layer 1 as an Observable.
SQLite (local-first apps): Transactions are fast. 300ms debounce is more than sufficient. The bottleneck is I/O, not query time.
PostgreSQL / Network databases: Consider a longer Layer 2 debounce (500ms+) to account for network latency. Consider optimistic local state with background sync.
Local-first with sync (PowerSync, CRDTs): Auto-save writes to the local database. The sync layer picks up changes asynchronously. No need to coordinate auto-save with sync timing - just save locally and let sync catch up.
Desktop (Electron, Tauri): Flush on before-quit and window-all-closed
events. In multi-process architectures (Electron), coordinate flush via IPC
between renderer and main process.
Mobile (React Native, SwiftUI): Flush on app backgrounding. On iOS, use
AppState listener. On Android, same approach works. Be aware that the OS can
terminate backgrounded apps without notice - flush must be synchronous (use
.flush(), not another debounced call).
Web (SPA): Flush on beforeunload event. Note: async operations in
beforeunload are unreliable. Consider using navigator.sendBeacon() for
critical saves, or ensure saves complete synchronously.
Debug carefully: know which layer you are looking at. When a save seems delayed or content seems stale, check whether the issue is in Layer 1 (undo batching) or Layer 2 (DB persistence). They are independent timers.
Flush both layers in tests. Async tests that check database state must wait for BOTH debounce layers to fire (~600ms total with default settings). Alternatively, mock timers carefully - two layers means nested setTimeout calls.
Do not flush Layer 2 synchronously on every document switch. Flush Layer 1 (commit the undo batch) synchronously, but let Layer 2 fire on its own schedule. The content-change event from Layer 1's commit will trigger Layer 2. Forcing both synchronously on every switch adds unnecessary latency.
The dirty flag is NOT the same as "has pending debounce." A document is dirty from the moment content changes until the database write succeeds. The debounce timers are internal implementation details. The dirty flag is the user-facing indicator.
Auto-save does NOT create versions. This is the most common misunderstanding when integrating with versioning. Auto-save calls updateVersion (in-place), never createVersion. See the Document Versioning recipe.
Undo history is ephemeral. It lives in memory only and is lost on app restart. This is intentional - persisting undo history adds significant complexity (serialization, storage, versioning the undo format itself) for marginal value. The version history system provides cross-session recovery.
Shared undo manager across platforms. If your app runs on multiple platforms (desktop + mobile), share the UndoManager class in a common package. Only the editor integration (input handling, keyboard shortcuts) and the flush triggers differ per platform.
Mobile battery impact. On mobile, every database write has a battery cost. Consider defaulting to a longer debounce interval (e.g., 500ms) on mobile than desktop (300ms). The difference is imperceptible to users but meaningful for battery life during extended editing sessions.
Document ID capture in debounced save. The debounced save closure must reference the document ID from when the content changed, not when the debounce fires. Otherwise, rapid document switching can cause saves to target the wrong document.