How would you manage performance if the undo stack in your app gets very large?
Three patterns scale better than naive deep-copy snapshots: (1) command pattern — store inverse operations, replay backward; (2) immutable persistent data structures (Immer, Immutable.js) — share unchanged subtrees, only diff allocates; (3) bounded stack with eviction (cap at N entries or M MB, drop oldest). Combine: command-based for size, immutable for correctness, bounded for memory cap. Use Web Worker for serialization/compression of cold history if needed.
Undo/redo "just works" at small scale by snapshotting the entire app state on every change. At scale (Figma-like docs with thousands of objects, ten-thousand-step histories), that's gigabytes of memory and seconds of GC pauses. Three patterns scale better.
1. Command pattern (inverse operations)
Don't store snapshots — store the operation and how to invert it.
type Command = {
do: (state: State) => State;
undo: (state: State) => State;
// optional metadata for redo merging, UI display
label?: string;
};
const stack: Command[] = [];
let cursor = 0;
function apply(cmd: Command) {
state = cmd.do(state);
stack.length = cursor; // drop redo branch
stack.push(cmd);
cursor = stack.length;
}
function undo() {
if (cursor === 0) return;
cursor--;
state = stack[cursor].undo(state);
}
function redo() {
if (cursor === stack.length) return;
state = stack[cursor].do(state);
cursor++;
}Memory cost: bytes-per-operation, not bytes-per-snapshot. A "move object" is a tiny { id, fromX, fromY, toX, toY } regardless of doc size.
Trade-off: every operation type needs an inverse. Some operations are hard to invert (e.g., "delete with cascading effects") and may need to store the deleted data alongside the command.
2. Immutable persistent data structures
Use Immer, Immutable.js, or built-in structural sharing in something like Redux. Each "snapshot" shares unchanged subtrees with the previous snapshot — only the changed path allocates new nodes.
import { produce } from 'immer';
const newState = produce(state, draft => {
draft.shapes[shapeId].x = 100;
});The new state shares all unchanged shapes by reference; only the path shapes → shapeId is reallocated. Memory cost ≈ depth of change × node size, not full doc size.
Stacking N snapshots: O(N × changed-path-size) memory, not O(N × doc-size).
3. Bounded stack + eviction
Cap the history at N entries or M megabytes:
const MAX_ENTRIES = 200;
function apply(cmd) {
stack.length = cursor;
stack.push(cmd);
cursor = stack.length;
if (stack.length > MAX_ENTRIES) {
stack.shift();
cursor--;
}
}Or budget by bytes — estimate each command's size, evict oldest until under budget. Show user "older history discarded" UI when the cap is hit.
4. Combine all three
The robust setup:
- Command pattern for low per-step cost.
- Immer for the underlying state so the current state's history-shared subtrees stay small.
- Bounded stack so memory is capped regardless of session length.
5. Coalesce contiguous operations
Don't push a new entry for every keystroke or every drag-tick. Coalesce:
function apply(cmd) {
const last = stack[cursor - 1];
if (last && canMerge(last, cmd, /* time window */ 500)) {
stack[cursor - 1] = mergeCommands(last, cmd);
} else {
stack[cursor++] = cmd;
}
}A typing session of "hello" becomes 1 undo entry, not 5.
6. Offload cold history
For very long sessions, move old entries off main heap:
- Serialize to IndexedDB: keep the recent N in memory, page older ones in on demand.
- Compress with
CompressionStream(or a Web Worker running pako) — serialized commands compress well. - Web Worker: run serialization + compression off the main thread to avoid INP regressions.
7. Handle non-undoable state separately
UI state (selection, hover, scroll position) shouldn't be in the undo stack — it'd undo into stale UI. Separate "document state" (in undo stack) from "session state" (not undoable).
8. Forward references / id management
Deleting an object and undoing the delete should restore the same id (so other commands referencing it still work). Use stable, allocated ids per object; on delete, remember the id and restore on undo.
Patterns in real apps
- Figma / Sketch: command pattern + bounded stack + per-doc serialization to disk. Memory per session is bounded.
- Photoshop: snapshot-based but with explicit "history brush" memory limits; offers "purge" actions.
- Notion / Linear: collaboration changes the model — operational transforms (OT) or CRDTs serve as both sync and history.
- Text editors (CodeMirror, Monaco): command pattern with coalescing for typing.
What to avoid
- Naive
JSON.parse(JSON.stringify(state))snapshots — quadratic memory + linear-in-doc-size GC pauses. - Unbounded stacks — sessions can run for hours; memory creeps until OOM.
- Storing event handlers / DOM refs / non-serializable objects in commands — breaks serialization to disk.
- Mixing UI selection state into doc state — undo "moves" the selection alongside content.
Mental model
Undo is just "store enough to reverse." Commands are cheap; snapshots are expensive. Cap memory explicitly. The user's expectation is N reversible steps, not infinity — and "older history discarded" is acceptable UX if N is generous (100–500).
Follow-up questions
- •How does CRDT-based collaboration change the undo model?
- •When would you store full snapshots instead of commands?
- •How do you coalesce typing for sensible undo granularity?
- •How do you serialize commands to disk safely?
Common mistakes
- •JSON snapshot per change — gigabytes after a long session.
- •Unbounded stack — OOMs after a few hours.
- •Putting UI state (selection) in the undo stack.
- •Not coalescing typing — one undo per keystroke is awful UX.
- •Storing non-serializable objects in commands — breaks persistence.
- •Restoring deleted items with new ids — breaks references in other commands.
Performance considerations
- •Command pattern is O(1) per step in memory and time. Immer-backed snapshots are O(depth of change). Naive deep-copy is O(doc size) per step — fatal at scale. Coalescing reduces stack growth 5–10x for input-heavy flows. Bounded stack caps memory; offload to IndexedDB extends it without burning RAM.
Edge cases
- •Concurrent multi-user edits (collab): per-user undo stacks need conflict resolution.
- •Undo across page reload: requires serialization to IndexedDB.
- •Some operations are partially irreversible (network side effects, server-saved changes) — surface that to the user.
- •Large media (images) in undo entries — store by hash + reference, dedupe.
- •Undo while a network mutation is in flight — queue or block.
Real-world examples
- •Figma's undo handles millions-of-object docs with bounded memory.
- •Notion / Google Docs use OT/CRDTs that double as undo history.
- •Photoshop / Illustrator have explicit 'history states' settings with memory tradeoffs.