Back to Performance
Performance
medium
mid

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.

9 min read·~15 min to think through

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.

ts
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.

ts
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:

ts
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:

ts
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.

Senior engineer discussion

Seniors design undo as a memory-budget problem from day one. They pick command-based for scale, coalesce for UX, cap for safety, and offload cold history when needed. They also separate doc state from session state and design for serialization so undo survives reloads.

Related questions