cf.completefrontend
My progress
SYSTEM DESIGN · #60

Design undo and redo

One undo and redo system that a canvas editor, a kanban board, and a form builder can all reuse. The hard parts are choosing commands over snapshots, deciding what one step is, keeping memory bounded, staying correct while every edit autosaves, and undoing only your own changes while teammates edit the same document.

The question

Our product has three editors: a canvas of shapes and connectors, a kanban board, and a form builder. Users in all three keep asking for undo and redo. Design it once, as a module every editor can use: the keyboard shortcuts, the buttons, and everything behind them.

Every edit already autosaves to our server, and up to 10 people can edit the same document at once. A canvas can hold 5,000 shapes.

Real-life examples: Cmd+Z in Figma while teammates edit the same file, Undo in Google Docs, which never removes a collaborator's text, Undo on Excalidraw and Miro boards, The Undo button in Gmail's toast after you archive an email

Requirements exploration

Undo looks like a two-line feature: keep a list of old states, go back one when the user presses Cmd+Z. That version works in a to-do app demo. It breaks in a real editor the first time a user drags a shape (60 history entries for one drag), types a sentence (one entry per letter), or presses undo while a teammate is editing (the teammate's work disappears). So the first five minutes go to questions whose answers change the design.

Questions to ask first

  • Whose changes does undo reverse? Mine only, or the last change anyone made? In a shared document the answer is always "mine", and that one answer is the hardest part of the design.
  • What is one step? A whole drag, or every pixel of it? A word, a sentence, or each letter? The browser reports about 60 pointermove events a second during a drag on a 60 Hz screen (more on faster screens), so grouping is not optional.
  • What can't be undone? Sending an invite email, publishing a form, charging a card. These leave the browser, so we need a different answer for them.
  • Does history survive a reload? Most editors say no. The document is safe on the server; the history is a convenience for the session.
  • Does undo restore selection and scroll? Users expect the changed objects to be selected and visible after an undo, but they don't expect Cmd+Z to undo a scroll.
  • How big can a document get? 50 cards on a board and 5,000 shapes on a canvas need different memory plans.
  • Is this one editor or a shared module? Here it's shared, so the history module can't know what a card or a shape is.

Functional requirements

  • Undo and redo every document change the user made: create, delete, move, resize, edit text, change a property, reorder.
  • Group events into steps the user would name: one drag is one step, a burst of typing is one step, "Align 12 shapes" is one step.
  • Shortcuts: Cmd+Z and Cmd+Shift+Z on macOS; Ctrl+Z, Ctrl+Y, and Ctrl+Shift+Z on Windows and Linux. Toolbar buttons and an Edit menu entry with the step's name, such as "Undo move card".
  • Undo only your own changes when others edit the same document, without erasing theirs.
  • Every undo and redo is saved to the server like any other edit.
  • Actions with effects outside the document (emails, payments, publishing) get a short undo window instead of a history entry.

Non-functional requirements

RequirementTargetWhy this number
Undo latencyUnder 50 ms from key press to paintUndo is pressed in rapid bursts; it must feel instant and keep INP well under 200 ms.
History depthAt least 100 steps, 200 by defaultProseMirror and CodeMirror both default to keeping 100 steps; users rarely go further.
History memoryUnder 20 MB per open documentLeaves room for the document itself on a 4 GB laptop or a mid-range phone.
Document sizeUp to 5,000 objectsThe canvas case; a kanban board is about 500 cards.
CollaboratorsUp to 10 editing at onceYour undo must keep working while others' edits keep arriving.
CorrectnessDo then undo returns the exact same documentAny drift corrupts documents silently; this becomes a test.

A quick estimate shows why grouping and size matter from the start. A busy user makes about one meaningful edit every 10 seconds, or roughly 1,000 steps in a three-hour session. Without grouping, the same session produces tens of thousands of raw changes: a single 2-second drag alone is 120 pointermove updates. So the 200-step limit covers about the last half hour of real work, which is what people actually undo.

Out of scope

  • Version history ("restore this document as it was last Tuesday"). It's a server feature with stored snapshots, not session undo, though the two can share ideas.
  • The text editor's own internal undo. A rich-text editor like ProseMirror or Lexical keeps its own history; we'll decide how it plugs in, but not rebuild it. The rich-text editor guide covers it.
  • The sync engine itself. We assume edits already flow to the server as operations; the Figma and Google Docs guides cover how.

Check your understanding · Exercise 1

Which of these answers would change the design the most? Select all that apply.

What makes undo hard

Before picking any data structure, name the properties of the problem. Each one drives a later chapter, and an interviewer who hears them early knows you've built this before.

  • Events are not steps. The browser speaks in keydown, input, and pointermove events. The user thinks in actions: "I moved the card", "I typed a title". Someone has to turn hundreds of events into a handful of named steps.
  • Undo is a write. Pressing undo changes the document. That change must be saved, synced to other people, and checked by the server, just like the original edit. The server may refuse it.
  • The document is shared. Between your edit and your undo, teammates may have changed the same objects. Undo has to reverse your change without destroying theirs, and sometimes it can't do both.
  • Not everything belongs in history. Selection, scroll, zoom, open panels, and the current route all change constantly. Recording them makes undo useless. Ignoring them makes undo disorienting.
  • Some effects leave the browser. An email that was sent can't be pulled back by popping a stack. Those actions need a time window or a compensating action instead.
  • History grows without end. A three-hour session can produce about 1,000 steps, and tens of thousands of raw changes. Without a budget, history quietly becomes the biggest object in the tab.
  • Several things own the keyboard. A focused text field has the browser's own undo stack. A rich-text editor has its own. The app has one more. Cmd+Z must go to the right one.
  • It's a shared module. The history can't know what a card, a shape, or a form field is. It must work on any document that can describe its changes.

Think first

Your teammate says: "Undo is client-side only. The server never needs to know." When is that true, and when is it wrong?

Notice what's missing from that list: drawing the buttons. The visible part of undo is two icons and a shortcut. Almost all the design work is in deciding what to record, how to keep it small and valid, and how it travels to the server and to other people. The rest of the guide takes these properties one at a time, starting with the most basic one: what a history entry stores.

Snapshots or operations: how to remember the past

The first real decision is what one history entry stores, and there are two families. Snapshots store what the whole document looked like. Operations store what changed and how to reverse it. Everything else (memory, server saves, multiplayer) follows from this choice.

The naive version and where it breaks

The simplest undo deep-copies the document before each change with structuredClone(doc) and pushes the copy onto a stack. Undo pops the stack and swaps the copy in. It's correct by construction, and for a form builder with 30 fields it's fine: each copy is a few kilobytes.

Now take the canvas. 5,000 shapes at about 300 bytes of JSON each is 1.5 MB per copy, and in the JavaScript heap objects take more room than their JSON text. 200 steps is over 300 MB of history to move three shapes now and then. Each copy also costs several milliseconds of cloning on every edit, and with multiplayer, swapping in an old copy erases every change a teammate made since.

Decision What does one history entry store?

Full snapshots

Before each step, deep-copy the whole document and push the copy.

Undo swaps the current document for the copy on top of the stack.

Pros

  • Trivially correct: no inverse logic to get wrong.
  • Works for any document shape, no per-action code.

Cons

  • Memory grows with document size times steps: 1.5 MB times 200 on our canvas.
  • Cloning cost on every edit.
  • Swapping in an old copy erases other people's edits.

Snapshots with structural sharing

Keep the document immutable. Each change copies only the path from the root to what changed; untouched objects are shared by reference between versions.

History is a list of document versions; undo points back at the previous one.

Pros

  • Undo is a pointer swap: instant.
  • Cheap per step: moving 3 shapes copies the shapes map and 3 shapes, not 5,000.
  • The natural fit for Redux and Zustand, which already keep immutable state.

Cons

  • A plain-object map of 5,000 entries is still copied in full on each change: about 40 KB of references per step.
  • Still a whole-document swap, so it still erases others' edits in multiplayer.
  • Nothing small and serializable to send to a server.

Commands with do and undo

Each action is an object with do() and undo() methods, written by hand per action type (the classic command pattern).

History stores the command objects; undo calls undo() on the top one.

Pros

  • Tiny entries.
  • Each action decides exactly how to reverse itself, including side effects.

Cons

  • Every action type needs a hand-written inverse; one bug corrupts documents.
  • Methods and closures can't be serialized, sent to a server, or transformed against others' edits.
  • Closures can keep old objects alive and leak memory.

Operations with inverses (patches)

Each change is described as data: a list of operations such as "set shapes.s1.x to 100", plus the inverse list, "set shapes.s1.x to 0", captured at the moment of the change.

Undo applies the inverse list; redo applies the forward list.

Pros

  • Small: a few hundred bytes for a typical step.
  • Plain data: can be sent to the server, stored in IndexedDB, logged, and transformed against others' operations.
  • Can be generated automatically from ordinary mutations (Immer patches) instead of hand-written.

Cons

  • Deletes must store the whole deleted object so undo can recreate it.
  • Inverses must be captured at change time, not computed later.
  • Applying ops is a little more work than a pointer swap.

What we pick: Operations with inverses. They're the only option that stays small at 5,000 shapes, can be saved to the server as ordinary edits, and can be adjusted when teammates edit the same things. Structural-sharing snapshots remain a fine choice for a single-user editor with a small document, and the history module can accept them through the same API; the shared document editors use operations.

Four ways to store history on a canvas of 5,000 shapes. Full snapshots cost about 1.5 MB for a step that moves 3 shapes, 300 MB for 200 steps, 1.5 MB for a delete of 500 shapes, and erase others' edits on undo. Structural sharing with plain objects costs about 40 KB, 8 MB, and 40 KB, and also erases others' edits. Structural sharing with a persistent map, a HAMT, costs about 1 KB, 200 KB, and 20 KB, and also erases others' edits. Inverse operations cost about 300 bytes, 60 KB for 200 steps, and 150 KB for the delete, and keep others' edits.move 3200 stepsdelete 500others' editsfull snapshot1.5 MB300 MB1.5 MBerasedshared, plain40 KB8 MB40 KBerasedshared, HAMT1 KB200 KB20 KBerasedinverse ops300 B60 KB150 KBkeptestimates from about 300 B of JSON per shape
Structural sharing fixes most of the memory cost of snapshots, but any snapshot still rewinds everyone's work. Inverse operations are the only model that stays small and reverses just your change; their one big cost is a mass delete, which must store what it deleted.

Getting inverses for free with Immer patches

Hand-writing an inverse for every action is where command-based undo goes wrong. Immer, the library behind Redux Toolkit's reducers, can record changes as you make them. produceWithPatches runs your ordinary mutating code on a draft and returns three things: the next state, the forward patches, and the inverse patches.

A patch is a plain object describing one change, in a format close to JSON Patch (RFC 6902), except that path is an array of keys instead of a string. applyPatches(state, patches) replays a list of them. The feature is opt-in since Immer 6: call enablePatches() once at startup.

ts
import { enablePatches, produceWithPatches, applyPatches } from "immer";

enablePatches(); // once, at startup

const [next, redo, undo] = produceWithPatches(doc, (draft) => {
  draft.shapes["s1"].x = 100;
  draft.shapes["s2"].x = 140;
});
// redo: [{ op: "replace", path: ["shapes", "s1", "x"], value: 100 }, ...]
// undo: [{ op: "replace", path: ["shapes", "s1", "x"], value: 0 }, ...]

history.push({ label: "Move 2 shapes", redo, undo });
setDoc(next);

// Later, when the user presses Cmd+Z:
const entry = history.undo();
if (entry) setDoc((d) => applyPatches(d, entry.undo));
Immer records forward and inverse patches while ordinary code mutates a draft. The history stores data, never functions.

Patches give us the size of operations with the convenience of snapshots, with two cautions. First, patches into arrays use indexes (["cards", 3]), and an index goes stale as soon as anything is inserted before it. That's why the document should be normalized: objects in maps keyed by stable ids, with order stored as a separate field such as a fractional position, as the normalization lesson and the kanban board guide explain.

Second, a delete's inverse is an add patch carrying the whole object. Deleting 500 shapes produces about 150 KB of inverse, which matters for the memory budget later.

Rough per-step costs, from JSON sizes and 8-byte references; real heap sizes run higher. A persistent map (a hash array mapped trie, as in Immutable.js) copies only a few small nodes per change.
ModelMove 3 of 5,000 shapes200 such stepsDelete 500 shapesOthers' edits on undo
Full snapshotabout 1.5 MBabout 300 MBabout 1.5 MBErased
Structural sharing, plain objectsabout 40 KBabout 8 MBabout 40 KBErased
Structural sharing, persistent mapabout 1 KBabout 200 KBabout 20 KBErased
Operations with inversesabout 300 bytesabout 60 KBabout 150 KBKept

Check your understanding · Exercise 2

A teammate proposes storing do() and undo() closures in the history instead of patches. Which problems would that cause? Select all that apply.

The history stack: past, present, and future

With operations chosen, the history itself is two stacks around the document. Past holds the steps you can undo, newest on top. Future holds the steps you've undone and can redo. The document itself is the present.

Undo moves the top of past to the top of future and applies its inverse ops; redo moves it back and applies its forward ops. The Redux docs describe the same shape (past, present, future) for snapshot-based undo. With operations, the present is simply the live document.

ts
type Entry = { id: string; label: string; redo: Op[]; undo: Op[] };
type History = { past: Entry[]; future: Entry[] };

function record(h: History, e: Entry): History {
  return { past: [...h.past, e], future: [] }; // a new edit clears redo
}

function undo(h: History, apply: (ops: Op[]) => void): History {
  const e = h.past.at(-1);
  if (!e) return h;
  apply(e.undo);
  return { past: h.past.slice(0, -1), future: [...h.future, e] };
}

function redo(h: History, apply: (ops: Op[]) => void): History {
  const e = h.future.at(-1);
  if (!e) return h;
  apply(e.redo);
  return { past: [...h.past, e], future: h.future.slice(0, -1) };
}
The core of every linear history. Everything later in this guide (grouping, limits, multiplayer) is a refinement of these three functions.

Try it below. Make a few edits, undo some, then make a new edit and watch the future stack empty. Switch the model to see what each entry would cost on a 5,000-shape canvas, turn grouping on and off, and lower the limit to watch the oldest entries fall off.

Model
Limit
History lab. Past stack: 0 entries. Future stack: 0 entries. Document: card at x 0, title empty, 5000 shapes. Model: ops, history memory about 0 B. Grouping on, limit 10, 0 entries dropped.past: 0 entries, newest on the rightempty: nothing to undocard at x 0, title "", 5000 shapesfuture: 0 entries, next redo on the leftempty: nothing to redomodel: inverse opsestimated memory: about 0 Bgrouping on, limit 10dropped so far: 0
Make a few edits, then undo and redo them.

What clears the redo stack

The rule "a new edit clears redo" needs care about what counts as a new edit. Get it wrong one way and redo vanishes when the user just clicked somewhere. Get it wrong the other way and redo replays a change on top of a document it no longer fits.

EventClears redo?Why
You move, type, delete, or pasteYesYour new change branches away from the undone steps.
You select, scroll, or zoomNoNothing in the document changed; redo still fits.
A teammate's edit arrivesNoIt isn't your branch. Redo entries get adjusted against it, like undo entries (see the multiplayer chapter).
The server rejects one of your edits and it's rolled backNoA rollback isn't a user edit; it removes the rejected step instead.
You reload the page or close the documentYes, history is goneHistory lives in memory unless you persist it deliberately.

Branching history, and why most apps throw the branch away

Say you make edits A, B, C, D, undo twice back to B, then make edit E. C and D are now on a branch nobody can reach with undo and redo. Most editors discard them, and users rarely notice. But it does lose work: a user who undid a little too far and then nudged something has lost C and D for good.

Branching history. Edits A, B, C, and D form a line. The user undoes twice, back to B. Then the user makes a new edit, E, which branches off B. C and D are now on a branch that undo and redo can no longer reach, and linear history discards them. A note says the safety net is version history on the server.ABCDundone, then lostEnew edit: redo now follows Eundo, undothe safety net:version historyon the server
After a new edit, redo can only follow one path, and it's the new one. Linear history throws C and D away; an undo tree would keep them, at the cost of a model most users never understand.

Decision What happens to undone steps when the user makes a new edit?

Discard the branch (linear history)

Clear the future stack on every new local edit.

Pros

  • What every user already expects from Word, Figma, and browsers.
  • Two simple stacks; easy to reason about and test.

Cons

  • Undone work is lost the moment you edit.

Undo tree

Keep every branch as a tree of states. A history panel or commands like Vim's g- and g+ walk between branches.

Pros

  • Nothing is ever lost.

Cons

  • Hard to present: users don't think in trees.
  • Memory grows with every abandoned branch.
  • Much harder to combine with multiplayer.

Undo is itself an edit (Emacs style)

Undoing records new edits on the stack, so the old branch stays reachable by undoing your undos.

Pros

  • Linear storage, yet nothing is lost.

Cons

  • Confusing: pressing undo repeatedly can redo things.
  • Few users of a visual editor would understand it.

What we pick: Linear history that discards the branch. It matches what users expect, and the real safety net for lost work is version history on the server, which keeps named and periodic versions of the document regardless of the session's undo stack.

Check your understanding · Exercise 3

You made edits A, B, C, then pressed undo once. Which of these should keep C available to redo? Select all that apply.

What counts as one undo step

Users undo intentions, not events. If one drag records 60 entries, the user presses Cmd+Z 60 times to move a card back, and decides undo is broken. If a whole minute of typing is one entry, one undo wipes a paragraph. Grouping decides where one step ends and the next begins.

How raw events become undo steps. A drag: pointerdown, five pointermove events, and pointerup, inside one transaction, become one step, Move card. Typing: the letters H, e, l, l, o, then a 600 ms pause, then an exclamation mark, become two steps, Type Hello and Type !. A paste of 20 shapes, made inside one group call, becomes one step, Paste 20.raw eventsundo stepsdragdown+4+4+4+4+4upMove card60 moves a second, one transactiontypingHello600 ms!Type HelloType !a pause, a space, or a caret jump ends the steppasteshapeshapeshape+17 morePaste 20one group() call, one step
A gesture, a pause in typing, or one group() call each make a single step. During the drag the document updates on every move, but history records only where the card started and where it ended.

Decision How do we turn a stream of changes into steps?

One entry per change

Every call that changes the document pushes its own entry.

Pros

  • No logic at all.

Cons

  • A drag is about 60 entries per second; typing is one entry per letter.
  • The history limit fills up with noise in seconds.

Time-based coalescing

Merge a change into the previous entry if it arrives within a short window (500 ms is the default in ProseMirror, CodeMirror, and Yjs) and is of the same kind.

Pros

  • Works without the caller doing anything.
  • Good for typing and held arrow keys.

Cons

  • Slow typists get letter-sized steps; fast ones get paragraph-sized steps.
  • Two unrelated quick actions can merge by accident.

Explicit transactions

The caller opens a group (history.begin("Move card")), makes any number of changes, then commits or cancels it.

Pros

  • Exactly one step per gesture or command, however many changes it makes.
  • Cancel can roll back everything, for example on Escape during a drag.

Cons

  • Every gesture has to remember to open and close its group, including on pointercancel.

Semantic boundaries

Start a new step at boundaries the user would recognize: after a space or punctuation, on a newline, when the caret jumps, or when typing turns into deleting.

Pros

  • Steps match words and phrases, which is how people think about text.

Cons

  • Only meaningful for text; needs knowledge of the editor.

What we pick: Combine them. Gestures and commands use explicit transactions, so a drag, a paste of 20 shapes, or "Align 12 shapes" is always one step. Text uses time-based coalescing at 500 ms, cut early at semantic boundaries and capped at a maximum group size so one long burst never becomes one giant step. Any code can force a break with history.checkpoint().

A drag as one step

On pointerdown the board opens a transaction labelled "Move card". Each pointermove updates the card's position in the document so other views and collaborators see it move, but the history records nothing yet. On pointerup the transaction commits one entry whose undo is "x back to where it started" and whose redo is "x to where it ended".

On Escape or pointercancel (the browser took the pointer for a scroll, or the window lost focus) the transaction cancels: it applies the inverse and records nothing. Held arrow-key nudges work the same way, closing the group about 500 ms after the last key, as the Figma guide describes.

How two entries merge

Merging two operation entries is mechanical. The forward ops run in order, so the merged redo is the first entry's redo followed by the second's. Undo runs backwards, so the merged undo is the second entry's undo followed by the first's. If both entries set the same field, compact them (keep only the last new value and the first old value), which turns 60 position updates into one pair.

ts
function merge(a: Entry, b: Entry): Entry {
  return {
    ...a,
    redo: compact([...a.redo, ...b.redo]),  // a then b
    undo: compact([...b.undo, ...a.undo]),  // undo b first, then a
  };
}

// A later "replace" of the same field overwrites the earlier one.
function compact(ops: Op[]): Op[] {
  const out: Op[] = [];
  let seen = new Map<string, number>(); // path -> index in out
  for (const op of ops) {
    const key = op.path.join("/");
    const i = seen.get(key);
    if (op.op === "replace" && i !== undefined) {
      out[i] = op; // nothing in between touched this path
      continue;
    }
    // Adds, removes, and writes to a parent or child path are barriers.
    const overlaps = [...seen.keys()].some((k) => k.startsWith(key + "/") || key.startsWith(k + "/"));
    if (op.op !== "replace" || overlaps) seen = new Map();
    if (op.op === "replace") seen.set(key, out.length);
    out.push(op);
  }
  return out;
}
Merging keeps the order rules straight: forward ops in time order, inverse ops in reverse. Compacting folds repeated replaces of the same field (the last one wins: the end position for redo, the start position for undo) and never folds across an add, a remove, or a write to a parent or child path.

Rules for typing

  • Start a new step if more than 500 ms passed since the last keystroke.
  • Start a new step after a word boundary: the first letter typed after a space, punctuation, or newline.
  • Start a new step when the kind changes: typing after deleting, or deleting after typing.
  • Start a new step when the caret jumps somewhere not adjacent to the last edit.
  • Cap a group at about 2 seconds or 100 characters, so continuous fast typing still produces several steps.

The recorder as a state machine

Transactions, coalescing, and applying undo all touch the same stacks, and bugs hide where they overlap: an undo pressed in the middle of a drag, or the ops that undo itself applies being recorded as a brand new edit. Modelling the recorder as a small state machine, as the state machines lesson recommends, makes those cases explicit. While undo or redo is applying, recording is off. While a transaction is open, undo first cancels it.

The history recorder as a state machine with three states. Idle: push records an entry, and may merge it with the previous one within 500 ms if the merge key matches. From idle, begin opens a transaction and moves to grouping. From grouping, commit pushes one entry and returns to idle, or cancel, on Escape or pointercancel, applies the inverse, records nothing, and returns to idle. A note says pressing undo while grouping cancels the transaction first. From idle, undo or redo moves to applying, where recording is paused; when the ops are applied, it returns to idle.idlepush() may mergegroupingtransaction openapplyingrecording pausedbegin()commit() or cancel()undo(), redo()ops appliedcancel appliesthe inverse andrecords nothingundo while groupingcancels it first
The applying state is what stops undo's own ops from being recorded as a new edit, which would clear redo. Undo pressed mid-drag never mixes with the open transaction: it cancels it.

Check your understanding · Exercise 4

A user drags a card for 2 seconds, then presses Escape before releasing. What should happen? Select all that apply.

What is undoable, and what isn't

Undo promises "put it back the way it was". That promise only holds for state the app fully controls. So for every kind of state, decide: is it a step, is it restored alongside a step, or is it outside history entirely?

StateIn history?What undo does with it
Document content: shapes, cards, fields, textYes, as stepsReverses the change.
SelectionStored with each step, never a step itselfRestores the selection from before the step, so the user sees what came back.
Scroll and zoomNoIf the changed objects are off-screen, scrolls them into view. Never "un-scrolls".
Open panels, hover, menusNoNothing.
Route and current pageNoBrowser back and forward own navigation. Undo never navigates.
Effects outside the app: emails, payments, publishingNoHandled by a delay window or a compensating action, below.

Selection deserves a closer look. If selection changes were steps, a user who clicked around five shapes and then pressed undo would get five useless undos before anything in the document changed. But if undo ignores selection completely, undoing a delete brings back a shape the user can't find.

The middle path: each entry stores selectionBefore and selectionAfter. Undo restores selectionBefore, redo restores selectionAfter, and either one scrolls the restored objects into view if they're outside the viewport.

Edits on another page

A canvas with several pages, or a form builder with several steps, raises a question: if my last edit was on page 2 and I'm now on page 3, what does undo do? One history per page means undo only reverses what's on the current page. That's predictable, but surprising when you just made a change and switched pages.

One history per document means undo can change something you can't see, so it must switch to page 2 first and select the restored objects. Design tools commonly keep one history per document and bring the change into view, and this design does the same. Either way, switching pages is never a step.

Actions with effects outside the document

Some actions on the board reach the outside world: inviting a teammate sends an email, and publishing a form makes it public. A history entry can't recall an email. Pretending otherwise is the worst outcome: undo shows the invite gone, and the email is already in the teammate's inbox.

Decision How do we handle an action whose effect leaves the app?

Not undoable, confirm first

Ask "Send invite to ana@example.com?" and don't create a history entry.

Pros

  • Honest and simple.

Cons

  • Confirmation dialogs get clicked through without reading.
  • Slows down every use of the action.

Delayed commit with an undo window

Show the result immediately, but hold the real request for a few seconds while a toast offers Undo. If Undo is pressed, cancel the request; it never happened.

Pros

  • True undo within the window, with no dialog.
  • Gmail's Undo Send works this way, with a window of 5 to 30 seconds.

Cons

  • The action really happens late.
  • If the tab closes during the window, either flush the request on pagehide (and when visibilitychange reports hidden) with keepalive or sendBeacon, or let the server hold it.

Compensating action

Let the effect happen, and make "undo" a new action that offsets it: unpublish the form, revoke the invite link, refund the charge.

Pros

  • Works after any amount of time.

Cons

  • Not a true undo: the email was still read, the form was public for a while.
  • Each action needs its own compensating API.

What we pick: Delayed commit for actions with a short natural window (invites, sends, bulk deletes on the server), exactly like the undo toast in the toast notifications guide: the toast's Undo cancels the pending request. Compensating actions for long-lived effects like publishing, labelled honestly ("Unpublish", not "Undo"). Neither goes on the Cmd+Z stack, because Cmd+Z must be safe to mash.

A delayed commit for sending an invite, on a time axis from 0 to 5 seconds. Top lane, no undo: the user clicks Send invite, a toast with Undo shows for 5 seconds, and when the window closes the app sends POST /invites. Bottom lane: the user clicks Send invite, the toast shows, the user presses Undo at about 2 seconds, and the request is never sent.no undoSend invitetoast with UndoPOST /invitesundo pressedSend inviteUndonever sent0 s2 s5 stime
The interface shows the invite as sent right away, but the request waits until the undo window closes. Undo inside the window is a true undo, because nothing has left the browser yet.

Check your understanding · Exercise 5

Which of these should be steps on the Cmd+Z stack? Select all that apply.

Keeping history inside a memory budget

History has no natural end: a designer working for three hours can make about 1,000 steps. Operations keep most steps tiny, but a few are large. Deleting 500 shapes stores all 500 so undo can restore them (about 150 KB), and pasting a big table into the form builder can be more. Without limits, history becomes the largest thing in the tab, and nobody notices until a laptop starts swapping.

Two limits, not one

A limit on entries alone (keep 200) is what most libraries offer, and it's a good start. But 200 tiny moves is about 60 KB, while 200 big deletes is about 30 MB. So the history keeps two limits: a maximum number of entries (200) and a maximum estimated size in bytes (20 MB).

When either limit is exceeded, the oldest entries are dropped until both fit. The oldest end is the right one to cut: users undo recent mistakes, almost never something from an hour ago.

ts
const MAX_ENTRIES = 200;
const MAX_BYTES = 20 * 1024 * 1024;

// Estimated once, when the entry is committed.
function estimateBytes(e: Entry): number {
  return 2 * (JSON.stringify(e.redo).length + JSON.stringify(e.undo).length);
}

function enforceLimits(h: HistoryState) {
  while (h.past.length > MAX_ENTRIES || h.bytes > MAX_BYTES) {
    const dropped = h.past.shift();
    if (!dropped) break;
    h.bytes -= dropped.bytes;
  }
}
JSON length is a cheap, stable proxy for heap size. Doubling it is a rough allowance for object and string overhead in the heap; the exact factor matters less than measuring every entry the same way. The sizes quoted elsewhere in this guide are plain JSON sizes.
An undo stack of seven entries from oldest to newest: move 150 bytes and type 300 bytes, both dropped; a delete of 500 shapes, 150 KB; an image, 200 bytes because it stores an asset id; align 1 KB; type 300 bytes; and the newest, move 150 bytes. Notes: the oldest drop first past 200 entries or 20 MB; an image entry keeps only the asset id, never the bytes; one huge entry is still kept.oldestnewestmove150 Btype300 Bdelete150 KBimage200 Balign1 KBtype300 Bmove150 Boldest drop firstpast 200 entriesor 20 MBimage: only theasset id, neverthe bytesone huge entryis still kept
Most steps are tiny; a big delete is the one that costs memory. Both limits are checked after every push, and entries are only ever dropped from the old end, so no gap opens in the middle of history.

Making big entries smaller

  • Store references, not payloads. An image added to the canvas keeps its asset id in the op, never the bytes. The file stays on the server (and in the HTTP cache), so the entry is 200 bytes instead of 2 MB.
  • Compact before storing. Fold repeated replaces of the same field when a group commits, as in the grouping chapter. A run of 40 arrow-key nudges on 3 shapes, coalesced into one step, shrinks from 120 ops to 3.
  • Tombstones instead of copies. If deleted objects stay in the document as tombstones, the undo of a delete is "set deleted to false", not a full copy of the object. That moves the cost into the document, where it's shared with sync and version history.
  • Compress old entries, maybe. Entries older than the last 20 could be serialized and compressed with the CompressionStream API. It's asynchronous and rarely worth the complexity when ops are already small; try the other three first.

Lifecycle and leaks

Keep one history per open document, in a Map keyed by document id, and delete it when the document closes. A history that outlives its document holds every deleted object forever. Entries must hold plain data only: no DOM nodes, no component instances, no closures over the old document. Any of those keeps far more memory alive than the entry's size suggests, and the byte estimate can't see it.

Think first

A user selects all 5,000 shapes and deletes them. Without tombstones, that one entry is about 1.5 MB. Should the history accept it, and what if one entry alone exceeds the 20 MB budget?

Check your understanding · Exercise 6

History is at 200 entries and 19 MB. The user deletes a group worth 3 MB of ops. What should happen? Pick one.

Keys, buttons, and screen readers

Most undo happens from the keyboard, in bursts: people press Cmd+Z four or five times quickly and watch. So the shortcut has to be right on every platform, it has to go to the right history, and the result has to be visible and announced.

The shortcuts by platform

Accept both redo keys on Windows and Linux; users bring habits from different apps. On the web, the touch gestures mostly reach text fields, so touch layouts need visible buttons.
PlatformUndoRedo
macOSCmd+ZCmd+Shift+Z
WindowsCtrl+ZCtrl+Y, and Ctrl+Shift+Z in many apps
LinuxCtrl+ZCtrl+Shift+Z, and Ctrl+Y in many apps
iPhone and iPadThree-finger swipe left, or shakeThree-finger swipe right
ts
// userAgentData.platform is "macOS"; navigator.platform is "MacIntel".
const isMac = /mac|iphone|ipad/i.test(
  navigator.userAgentData?.platform ?? navigator.platform,
);

function onKeyDown(e: KeyboardEvent) {
  if (e.isComposing || e.altKey) return;    // IME input, other shortcuts
  const mod = isMac ? e.metaKey : e.ctrlKey;
  if (!mod) return;
  const key = e.key.toLowerCase();          // Shift may report "Z"
  const isUndo = key === "z" && !e.shiftKey;
  const isRedo = (key === "z" && e.shiftKey) || (!isMac && key === "y");
  if (!isUndo && !isRedo) return;
  if (isNativeTextField(e.target)) return;  // let the browser's undo run
  e.preventDefault();
  if (isUndo) history.undo(); else history.redo();
}

window.addEventListener("keydown", onKeyDown);
One listener for the whole editor. It skips IME composition and native text fields, and checks the modifier for the platform so Ctrl+Z on a Mac isn't taken over.

Native text fields have their own undo

Every input and textarea keeps its own undo stack, managed by the browser. If focus is in the form builder's "Field label" input and the user presses Cmd+Z, they mean "undo my last few letters", not "undo the field I added a minute ago". That native stack is fragile: values set from JavaScript (input.value = ...) are not on it, and they can break it.

The deprecated document.execCommand() is the one API whose edits (insertText, for example) land on the native stack, as MDN notes, and execCommand("undo") undoes the last step on that stack. Neither is a base for an app's history, which is one more reason to keep the native stack short-lived.

Decision Who handles Cmd+Z while focus is in a text field?

The app, always

The global listener calls preventDefault() everywhere and runs app undo.

Pros

  • One history, one behavior.

Cons

  • Undoing a typo inside a field undoes something else entirely.
  • Every field's typing must be recorded in app history letter by letter.

The browser, always, inside fields

Never intercept while a field is focused.

Pros

  • Native behavior, zero code.

Cons

  • Once the field's own stack is empty, Cmd+Z does nothing, and the user is stuck until they click out.

Hybrid: native while editing, app after commit

While a plain field is focused, the browser handles undo of in-progress typing.

When the field commits (on change or blur), its new value goes to app history as one step: "Rename field to Full name".

Editors the app owns fully, like the canvas text tool or a rich-text editor, route undo to their own history, which feeds the app history.

Pros

  • Each Cmd+Z does what the user means in that place.
  • A whole field edit is one app step, which is the right size.

Cons

  • Two stacks the user moves between; needs the commit points to be clear.

What we pick: Hybrid. It matches how desktop apps behave: undo inside a text box edits the text, undo outside edits the document. Press Escape or click out, and Cmd+Z undoes the whole rename in one step.

Editable elements get one more signal. When the user picks Edit, Undo from the browser's menu or the context menu, there's no keydown at all. Instead the browser fires a beforeinput event whose inputType is historyUndo or historyRedo (from the Input Events specification) on the focused input, textarea, or contenteditable element.

The specification makes these two input types cancelable, so ProseMirror and CodeMirror listen for them, cancel the browser's undo, and run their own history. Check event.cancelable before relying on preventDefault(), since MDN warns that some beforeinput events can't be cancelled. The event never fires on non-editable elements, so a canvas whose focus sits on a plain element must offer its own Edit menu and buttons.

Keyboard undo as a sequence between the User, the Shortcuts listener, History, the Store, and the Outbox. The user presses Cmd+Z. Shortcuts checks that focus is not in a native text field, calls preventDefault, and calls undo on History. History applies the inverse ops to the Store. The Store hands the ops to the Outbox to send. The Store re-renders and reselects the card. History announces the undo by writing Undid move card to the status live region.UserShortcutsHistoryStoreOutboxCmd+Zundo()apply inversesend opsreselect cardannounce undo
If focus had been in an input, the listener would have stepped aside and the browser's own text undo would run instead. The server only ever sees the ops, never the word undo.

Buttons, labels, and the Edit menu

  • Name the step. The tooltip and menu item say "Undo move card" and "Redo rename field", read from the top entry's label. Users undo with more confidence when they know what will happen.
  • Disabled state. When canUndo is false, the button is disabled. Prefer aria-disabled="true" over the disabled attribute inside a toolbar, so keyboard and screen reader users can still find the button and hear why it does nothing.
  • Shortcut hints. Show Cmd+Z or Ctrl+Z in the tooltip for the user's platform, and set aria-keyshortcuts on the button.
  • Show the change. After an undo, restore the selection, scroll the objects into view, and briefly highlight them. An undo that happens off-screen looks like a broken button.
Wireframe of a kanban board editor right after an undo. The toolbar has an Undo move card button with the hint Cmd+Z, a disabled Redo button, an open Edit menu listing Undo move card with Cmd+Z and Redo with nothing to redo, and a status text, All changes saved. The board has three columns: To do with card 17, Doing with card 42, just moved back and selected, and card 8, and Done with card 5. At the bottom, a visually hidden status region says Undid move card.Board editorUndo move cardRedoEditAll changes savedCmd+ZUndo move card, Cmd+ZRedo: nothing to redoTo doDoingDoneCard 17Card 42Card 8Card 5back, selectedrole=status: Undid move card
The step's name appears on the button, in the menu, and in the announcement. Redo stays visible but disabled, and the restored card is selected and highlighted so the change is easy to see. The role="status" region is hidden from view but read aloud.

Announcing undo to screen readers

A sighted user sees the card jump back. A screen reader user hears nothing unless we say it. After each undo or redo, write a short message to a polite live region (an element with role="status" whose text changes are read aloud): "Undid move card", "Redid delete 3 shapes".

When the user presses undo five times in a second, announce only the last one, after about 150 ms of quiet, or the reader queues five messages. If undo removes the element that had focus (undoing "Add card"), move focus to a sensible neighbor, such as the previous card or the column, never to the page body. The screen readers and keyboard navigation lessons cover live regions and focus in depth.

Check your understanding · Exercise 7

Focus is in the form builder's "Field label" input. The user typed "Full nme" and presses Ctrl+Z on Windows. What should happen? Select all that apply.

Undo meets autosave and the server

Every edit in our editors autosaves. That turns undo from a local trick into a distributed one: the user's undo has to reach the server, other tabs, and other people, and the server can refuse it.

To the server, undo is just another edit

When the user undoes "Move card to Done", the history applies the inverse ops locally, and they go to the same outbox that sends every other edit: "set card 42's column to Doing, position a3". The server doesn't need an undo endpoint and doesn't need to know where the ops came from. That keeps one write path, one set of permission checks, and one conflict policy. It also means undoing right after an autosave simply triggers another save, which is correct: the document really changed again.

Decision How does an undo reach the server?

Save the whole document after undo

Autosave serializes the whole document and sends it after any change, including undo.

Pros

  • Nothing special for undo.

Cons

  • 1.5 MB per save on the canvas.
  • Last whole-document save wins: it overwrites teammates' concurrent edits.

A special undo request

Send POST /documents/42/undo with the entry id and let the server reverse it.

Pros

  • The server can keep its own history.

Cons

  • The server must know every client's stack and grouping.
  • Two write paths with different rules and different bugs.

Inverse ops through the normal outbox

Undo produces ordinary ops; the outbox sends them in order with the base version, exactly like any edit.

Pros

  • One write path.
  • Small payloads.
  • Works offline: undos queue with everything else.

Cons

  • The server can reject an undo like any edit, so the client needs a rollback plan.

What we pick: Inverse ops through the normal outbox. Undo stays a purely client-side idea, and the server sees a clean stream of ordinary edits.

When the server refuses an undo

The undo's ops can fail like any edit: an admin locked the card, a teammate deleted the column the card would return to, or the user's role changed to viewer. Because the ops were applied optimistically, the client rolls them back when the server says no.

The question is what happens to the history entry. It's no longer valid, so it's dropped from the redo stack (where the undo had moved it), and the user is told why: "Couldn't undo move: the Doing column was deleted by Ana."

Undo and the server as a sequence between the Board UI, History, the Outbox, and the Server. The user moves card 42 to Done; History records the entry and gives the Outbox the ops, Doing to Done. The Outbox sends them with base version 41, and the Server acks version 42. The user presses Cmd+Z: History gives the Outbox the inverse, Done to Doing, sent with base version 42. The Server rejects it because the Doing column was deleted. The Outbox reports the rejection and the ops are rolled back, then History drops the entry and the Board UI explains why.Board UIHistoryOutboxServermove 42 to Doneops: Doing to Donesend, base v41ack v42Cmd+Zinverse: to Doingsend, base v42column deletedroll back opsdrop, explain
The server never sees an undo, only ordinary ops. When it rejects one, the client rolls the change back without recording anything, drops the entry so it can't fail again, and tells the user which teammate's change got in the way.

Ordering: undo must never overtake its edit

Fast users undo before the original edit has been saved. If the undo's ops took a different path, or were sent in parallel, the server could apply "move back" first and "move" second, leaving the card in the wrong column while the client shows it correctly. Sending everything through one ordered outbox, each batch with the version it was based on, rules this out.

A good outbox also cancels out pairs that never left. If "move" is still queued when its undo arrives, both can be dropped before sending.

Should history survive a reload?

Most editors drop history on reload, and users accept it, because the document itself is safe on the server. Persisting history is possible: write entries to IndexedDB as they're committed, keyed by document id, and load them on open. The trap is staleness: by the time the user returns, teammates may have changed the document many times, so old inverse ops no longer fit.

Store the document version with each entry, and on load keep only entries whose version is still current, or run them through the same transform as remote edits (next chapter). For most products the honest answer is no persisted undo, plus server-side version history for anything older. The IndexedDB lesson covers the storage side if you do need it, for example for an offline-first editor.

Check your understanding · Exercise 8

The user moves a card, the autosave succeeds, then the user presses undo and the server rejects the undo's ops because the card is now locked. Which statements are right? Select all that apply.

Undo when other people are editing

Up to 10 people edit the same document. Ana moves card 42 to Done, a second later Ben renames it, and then Ana presses Cmd+Z. She means "put my card back", not "undo whatever happened last" (Ben's rename), and certainly not "restore the board as it was before my move", which also erases Ben's rename and everything else since.

The operational transformation lesson calls this intention preservation: an operation should keep doing what its author meant, even after others' operations land around it. Try the three answers below and watch what happens to Ben's work.

Undo mode
Multiplayer undo lab in selective mode. The card is titled Fix login and sits in Doing. The shared log has 0 operations. Ana's undo stack has 0 steps. Ben's work is intact.Fix logincolumn: Doingshared log, newest on the rightno edits yetmode: selective undoAna's undo stack: 0Ben's work: intactonly Ana's ops undone
Ana moves the card, Ben edits it, then Ana presses undo.

Decision What does undo reverse in a shared document?

Global undo

One shared history of everyone's edits; undo reverses the newest, whoever made it.

Pros

  • Simple to explain for a single shared screen, like a whiteboard on a TV.

Cons

  • You undo work you never saw, made by someone else.
  • Two people pressing undo fight over the same stack.

Local undo with stale inverses

Each client keeps only its own steps, and applies the stored inverse ops exactly as recorded.

Pros

  • Never touches steps that aren't yours.
  • No extra machinery.

Cons

  • Inverses go stale. "Delete characters 10 to 13" deletes the wrong text once Ben inserts 5 characters before position 10.
  • Writing back an old value overwrites a teammate's newer one without warning.

Selective undo

Each client tracks only its own steps (by origin), and keeps every entry's ops valid as remote ops arrive: positions are transformed, and property writes are checked against what changed since.

Undo applies the adjusted inverse and sends it as a normal edit.

Pros

  • Reverses exactly your intention and leaves others' work alone.
  • Matches what Google Docs, Figma, and Yjs-based editors do.

Cons

  • Needs a transform function or a CRDT, plus a policy for conflicts that can't be fixed automatically.

What we pick: Selective undo. Each history only holds the local user's steps, and the module asks the document layer to keep those steps valid as remote operations arrive. The document layer already knows how to do this, because it merges concurrent edits for sync.

Two kinds of data, two ways to stay valid

Properties of objects with ids (a card's column, a shape's fill, a field's label) need no position math. The op says "set card 42's column", and card 42 is card 42 no matter what others did. The only question at undo time is whether someone changed the same property after you.

The Figma guide works through that case and its redo rule: record the redo values at the moment of undo, so undo then redo always leaves the document unchanged. That guide writes your old value back anyway. For kanban cards and form fields we pick the other reasonable policy: skip any property someone else changed since your step, and say so ("Ben renamed this card after your change, so the name wasn't undone"). If nothing in the step still applies, skip the whole step, so the next Cmd+Z does something useful.

Positions in sequences (characters in a text, items in a list ordered by index) need transformation. Your inverse "delete 4 characters at 10" was correct when you typed them. If Ben inserts 5 characters at position 3 afterwards, the same text now sits at 15. Every remote op that arrives must shift the positions in your stored entries, both undo and redo, using the same transform function operational transformation uses for sync (the OT lesson covers how it's written).

With a CRDT such as Yjs, characters have stable ids instead of indexes, so an inverse refers to ids and never goes stale. Yjs ships a selective UndoManager: by default it tracks only local changes made without an explicit transaction origin (network providers usually tag remote updates with themselves as the origin), you can choose other trackedOrigins, and it merges changes made within its captureTimeout of 500 ms.

ts
type TextOp =
  | { type: "insert"; at: number; text: string }
  | { type: "delete"; at: number; length: number };

// Shift a stored op so it still targets the same text after a remote op.
function shiftAgainst(op: TextOp, remote: TextOp): TextOp {
  if (remote.type === "insert" && remote.at <= op.at) {
    return { ...op, at: op.at + remote.text.length };
  }
  if (remote.type === "delete" && remote.at < op.at) {
    const removedBefore = Math.min(remote.length, op.at - remote.at);
    return { ...op, at: op.at - removedBefore };
  }
  return op; // remote op is after ours: no change
}

// On every remote op, rebase both stacks.
function onRemoteOp(remote: TextOp) {
  for (const e of [...history.past, ...history.future]) {
    e.undo = e.undo.map((op) => shiftAgainst(op, remote));
    e.redo = e.redo.map((op) => shiftAgainst(op, remote));
  }
}
A simplified transform for plain text. Real ones also handle a remote delete that overlaps the stored op and ties at the same position; the operational transformation lesson walks through those cases.
Why stored inverses must be rebased. The text starts as The fox jumps. Ana inserts fast at position 4, giving The fast fox jumps, and her undo is delete 5 characters at 4. Ben then inserts Look colon at position 0, giving Look: The fast fox jumps. A stale undo, delete 5 at 4, removes colon space The and leaves Look fast fox jumps, damaging Ben's text. A rebased undo, delete 5 at 10, removes fast and leaves Look: The fox jumps.1. startThe fox jumps2. Ana inserts at 4The fast fox jumpsundo: delete 5 at 43. Ben inserts at 0Look: The fast fox jumpsstale: delete 5 at 4removes : Therebased: delete 5 at 10removes fastLook fast fox jumpsLook: The fox jumpsBen's text damagedonly Ana's change undone
Ben's 6 characters landed before Ana's text, so every position after them moved right by 6. Rebasing the stored inverse against Ben's op keeps it pointing at the same characters Ana typed.

Edge cases

  • The object was deleted by someone else. Undoing your move of card 42 after Ben deleted it would resurrect a card he removed on purpose. Skip it and say why.
  • Your undo depends on someone else's work. You added a card to a column Ben created; Ben then undoes the column. Your card goes with it (or moves to a fallback column, if the board has that rule). When you then undo "Add card", there is nothing left to undo: skip.
  • Rebasing cost. Shifting 200 entries for every remote op is cheap for text (a few microseconds each), but batch it: apply remote ops in one pass per animation frame, not one pass per op.
  • Remote ops never clear your redo. They adjust it, like the undo stack.

Check your understanding · Exercise 9

Ana typed "fast " at position 10 of a shared text. Ben then inserted "Very " (5 characters) at position 0. Ana presses undo. Which statements are right? Select all that apply.

Architecture, data model, and the history API

Now put the pieces together. The goal from the requirements was one module that the canvas, the kanban board, and the form builder all reuse. That forces a clear line: the history module knows about entries, groups, limits, and stacks; it knows nothing about cards or shapes. Everything domain-specific sits behind a small adapter each editor provides.

Architecture of the undo system. Inside the browser tab: the Editor UI for the canvas, board, or form sends intents to the Command layer, which turns them into ops plus inverses and applies the ops to the Document store. The store reports each labelled local change to the History manager as an entry, and each remote op as a rebase. The Undo UI, with keys, menu, and status, calls undo on the History manager. The History manager asks the Doc adapter to apply ops, and the adapter applies them to the Document store. The store gives ops to the Sync outbox, which exchanges ops with the Server in both directions. The Effects queue sends delayed commits to the Server later. A note says the history knows nothing about cards or shapes.Browser tabEditor UIcanvas, board, formUndo UIkeys, menu, statushistory knowsnothing aboutcards or shapesCommand layerops + inverseHistorystacks, groupsDoc adapterapply, rebaseSync outboxordered opsDocument storenormalizedEffects queuedelayed commitsServer: ops log, invite mailerintentundo()applyentry, rebaseopsops both wayslater
The history manager is generic: everything it needs to know about cards, shapes, or fields goes through the adapter. Undo ops leave through the same outbox as every other edit, and outside effects take their own delayed path.
PartOwnsDoesn't own
Editor UI (canvas, board, form builder)Rendering, gestures, opening and closing transactions around gesturesHistory stacks; it calls the command layer
Command layerTurning an intent ("move card") into ops plus inverse ops and a label, usually with Immer patchesSending anything; it only describes changes
Document storeThe current document, normalized by id; applying ops; notifying subscribersWhich ops are undoable
History managerPast and future stacks, grouping, limits, the recorder state machine, canUndo and canRedoWhat an op means; it calls the adapter to apply and rebase
Document adapterapply(ops), rebase(entry, remoteOp), stillApplies(op) for this editor's dataStacks or UI
Undo UI and shortcutsKey routing, buttons with labels, the Edit menu, the live regionAny document logic
Sync outboxSending local ops in order with base versions, receiving remote ops, reporting rejectionsWhether an op came from undo
Effects queueDelayed commits for outside effects (invites, sends) with their undo windowDocument history

Two boundaries matter most. First, the history manager sits beside the document store, not inside it. The store applies ops from three sources (local edits, undo and redo, and remote users), but only local edits add entries, and remote ops only rebase them.

Second, the outbox doesn't know about undo at all. That's what keeps one write path to the server: undo ops and normal ops are indistinguishable once they leave the history manager.

How data moves

Follow one edit and its undo end to end. A local edit flows from gesture to command to store, and the store reports it to history (as a labelled entry) and to the outbox (as ops). An undo flows from the shortcut to history, which hands the inverse ops through the adapter to the store, and the store sends them to the outbox like any edit. A remote op flows from the socket through the outbox to the store, and then to history, which rebases its entries against it.

Data flow for edits and undo. The Editor UI sends edit ops to the Document store, which sends ops with base version 41 to the Sync outbox, and the outbox sends them to the Server, which acks. The Server's remote ops come back through the outbox to the store. The store sends each local change to History as an entry, and each remote op as a rebase. The store re-renders the Editor UI. The Undo UI calls undo on History, History sends inverse ops to the store, and History gives the Undo UI the label and canUndo.Editor UIpointer, keysDocument storenormalizedSync outboxordered opsUndo UIkeys, buttonsHistorypast, futureServerops logedit opsre-renderops, v41remote opssendackentry or rebaseundo: inverse opsundo()label, canUndo
Three flows share one store. Local edits create entries, undo sends inverse ops back through the store like any edit, and remote ops only rebase history, never add to it.

Main thread, tabs, and lifetimes

The history manager runs on the main thread, next to the store. Its work per keypress is tiny (move one entry between two arrays, hand a few ops to the store), so a Web Worker would only add a message round trip to every undo. The expensive parts, rendering 500 restored shapes or transforming text, happen in the store and the view anyway.

Two tabs open on the same document each get their own history. To each other they're simply two collaborators: tab B's edits arrive at tab A as remote ops, rebase A's entries, and never appear in A's undo stack. That falls out of the design for free, with no BroadcastChannel coordination of stacks. The history is created when a document opens and dropped when it closes, so switching between ten documents in a session never stacks up ten histories in memory.

Think first

Why not put the history inside the document store, as a Redux reducer that wraps the document reducer?

Check your understanding · Exercise 10

A remote op arrives over the socket. Which parts does it pass through? Select all that apply.

The history data model

Inside that architecture, the history module stores plain data. Each entry is a labelled pair of op lists plus the context needed to restore the user's view. The state around it tracks the stacks, the open group, and the byte count for the budget.

ts
type Op = { op: "add" | "remove" | "replace"; path: (string | number)[]; value?: unknown };

type HistoryEntry = {
  id: string;
  label: string;              // "Move card", shown as "Undo move card"
  redo: Op[];                 // forward ops, in order
  undo: Op[];                 // inverse ops, in reverse order
  selectionBefore: string[];  // object ids to reselect after undo
  selectionAfter: string[];   // ... and after redo
  pageId?: string;            // where to navigate to show the change
  baseVersion: number;        // document version when recorded
  bytes: number;              // estimated once, for the budget
  createdAt: number;          // for time-based coalescing
  mergeKey?: string;          // "type:card-42:title" merges with the same key
};

type HistoryState = {
  past: HistoryEntry[];       // newest last
  future: HistoryEntry[];     // next redo last
  open: { label: string; redo: Op[]; undo: Op[] } | null; // a transaction
  mode: "idle" | "grouping" | "applying";
  bytes: number;
  limits: { maxEntries: number; maxBytes: number; mergeWindowMs: number };
};
Entries are serializable on purpose: they can be logged, persisted to IndexedDB, and rebased against remote ops without special cases.
The history data model. The past stack holds Add card, Rename, and the newest, Move card. The present is the document at version 42. The future stack holds Delete 3. The Move card entry opened up: label Move card, redo sets the column to done, undo sets it back to doing, selection card-42. Its metadata: base version 41, 180 bytes, no merge key. The history state also has open null and mode idle.past, newest lastpresentfutureAdd cardRenameMove carddoc v42Delete 3open: nullmode: idlelabel: Move cardredo: column to doneundo: column to doingselection: card-42baseVersion: 41bytes: 180mergeKey: noneplain data only: ids, never DOM nodes or closures
Every field is plain data: ids instead of DOM nodes, op lists instead of functions. That's what lets the same entry be rebased, sent, measured, and, if you choose, stored in IndexedDB.

A few fields earn their place. label is stored, not computed, because by the time the user hovers the Undo button the objects it names may have changed. baseVersion lets a persisted or rebased entry be checked against the current document, and selectionBefore and selectionAfter hold ids, never DOM nodes, so entries stay plain data.

mergeKey is how coalescing knows that two edits belong together. Keystrokes in the same card title share a key such as type:card-42:title, so typing in a different card always starts a new step, even within 500 ms.

StateLives inWhy there
The documentDocument store (normalized by id) or a CRDT documentShared by every view; synced to the server
History stacksHistory manager, one per open document, in memorySession-scoped; not part of the document
Selection and viewportEditor UI stateRestored by history, never recorded as steps
Pending outside effectsEffects queue, with a timer per actionMust be cancellable until the window closes
Unsent opsSync outbox, optionally mirrored to IndexedDBMust survive a flaky network, and a reload if the editor is offline-first

The API every editor uses

ts
interface History {
  // Recording
  push(entry: { label: string; redo: Op[]; undo: Op[]; mergeKey?: string }): void;
  begin(label: string): void;          // open a transaction (a gesture)
  commit(): void;                      // close it as one entry
  cancel(): void;                      // roll it back, record nothing
  group<T>(label: string, fn: () => T): T; // begin + fn + commit, or cancel on throw
  checkpoint(): void;                  // the next push never merges

  // Moving
  undo(): HistoryEntry | null;
  redo(): HistoryEntry | null;
  clear(): void;                       // on document close or reset

  // Reading, for buttons, menus, and tests
  canUndo(): boolean;
  canRedo(): boolean;
  undoLabel(): string | null;          // "Move card"
  redoLabel(): string | null;
  subscribe(listener: () => void): () => void;
}
Small on purpose. group covers commands like "Align 12 shapes"; begin, commit, and cancel cover gestures; subscribe lets React read it with useSyncExternalStore.

Each editor creates its history with an adapter: createHistory({ apply, rebase, stillApplies, limits }). The canvas adapter applies Immer patches to its store and rebases text ops; the form builder's adapter only applies patches, because it has no shared text. That's the whole reuse story: three editors, one module, three small adapters.

Plugging into Redux and Zustand

Both libraries already have a pattern for wrapping state updates, and that's where recording belongs. In Redux, a middleware (or Redux Toolkit's Immer-powered reducers with patches enabled) can capture patches for actions marked undoable. In Zustand, a store middleware wraps set.

Ready-made libraries exist: redux-undo wraps a reducer with past, present, and future snapshots, and zundo adds a temporal middleware to Zustand. Both are snapshot-based and single-user, which makes them a good fit for the form builder alone and a poor fit for the shared canvas.

ts
import { create } from "zustand";
import { enablePatches, produceWithPatches } from "immer";

enablePatches();

// Inside a Zustand store: every recorded update goes through `edit`.
const useBoard = create<BoardState>()((set, get) => ({
  doc: initialDoc,
  edit(label: string, recipe: (draft: Doc) => void) {
    const [next, redo, undo] = produceWithPatches(get().doc, recipe);
    if (redo.length === 0) return;     // nothing changed: no entry
    set({ doc: next });
    history.push({ label, redo, undo });
    outbox.send(redo);
  },
}));

// A component:
useBoard.getState().edit("Move card", (d) => {
  d.cards["42"].columnId = "done";
});
One edit function is the only door for undoable changes, so nothing reaches the document without an entry, and nothing gets an entry without being sent.

Check your understanding · Exercise 11

A developer adds "Align 12 shapes" by calling edit 12 times in a loop, once per shape. What goes wrong, and what fixes it? Select all that apply.

Testing, performance, and observability

Undo bugs are silent. A wrong inverse doesn't throw; it leaves the document a little different from before, and the user finds out days later. So the most important tests aren't examples but properties that must hold for every sequence of edits.

Property tests: do, then undo, returns the same document

A property test generates hundreds of random inputs and checks a rule for each one. Libraries like fast-check generate random sequences of edits, run them, and when a rule fails, shrink the sequence to the smallest one that still fails, which is usually two or three steps that point straight at the bug.

ts
import fc from "fast-check";

const anEdit = fc.oneof(
  fc.record({ kind: fc.constant("move"), id: fc.constantFrom("a", "b", "c"), x: fc.integer() }),
  fc.record({ kind: fc.constant("rename"), id: fc.constantFrom("a", "b", "c"), name: fc.string() }),
  fc.record({ kind: fc.constant("delete"), id: fc.constantFrom("a", "b", "c") }),
);

test("undoing every edit restores the start", () => {
  fc.assert(fc.property(fc.array(anEdit, { maxLength: 50 }), (edits) => {
    const editor = createTestEditor(seedDoc);
    for (const e of edits) editor.run(e);
    while (editor.history.canUndo()) editor.history.undo();
    expect(editor.doc()).toEqual(seedDoc);

    while (editor.history.canRedo()) editor.history.redo();
    expect(editor.doc()).toEqual(runWithoutHistory(seedDoc, edits));
  }));
});
Two rules in one test: undo all returns the start, and redo all returns the end. Keep the limits above 50 here, or the test must stop at the oldest kept entry.
  • Grouping rules, with fake timers: keystrokes 100 ms apart merge, a 600 ms pause splits, a space starts a new step, cancel() records nothing.
  • Limits: after 250 pushes there are 200 entries, and the oldest are the ones gone; a single entry over the byte budget is still kept.
  • Multiplayer convergence: two simulated clients make random edits and random undos, exchange ops in random order, and must end with identical documents in which neither user's surviving edits were reverted by the other's undo.
  • Keyboard routing, in an end-to-end test: Cmd+Z inside a focused input changes the input, not the board; outside it, the board changes and the live region says what was undone.

Making undo feel instant

Our target was under 50 ms from key press to paint. Most steps touch a few objects and take well under a millisecond to apply. The slow cases are big: undoing a delete of 500 shapes re-inserts 500 objects. Three rules keep those fast.

  • Apply all of an entry's ops in one store update and notify subscribers once. 500 separate updates mean 500 re-renders; one update means one.
  • Subscribe narrowly. Components select their own object by id, so undoing a move of card 42 re-renders card 42 and its column, not the whole board. Normalized data makes that possible.
  • Keep held keys cheap. A held Cmd+Z repeats about 10 to 30 times a second. Each undo must finish well inside a frame, and announcements wait for a quiet moment, as the keyboard chapter described.

What to measure in production

  • Undo and redo counts per session, and the labels most often undone: a feature that gets undone a lot is often a confusing feature.
  • Skipped or failed undos with their reason (deleted by someone else, rejected by the server, changed by someone else). A rising rate means the conflict policy needs work.
  • History size at the 95th percentile, in entries and estimated bytes, and how often the byte budget drops entries.
  • Time to apply an undo, as a performance.measure, with the entry's op count.

Check your understanding · Exercise 12

A property test fails, and fast-check shrinks it to: delete shape a, rename shape a, undo, undo. What's the most likely bug? Select all that apply.

Running it in the interview

Undo is a feature question, so the interviewer expects depth on a few mechanisms rather than a tour of a whole app. In 45 minutes you won't cover everything in this guide. Pick the parts that show judgment, and say out loud what you're leaving out.

MinutesWhat to doWhat to draw or say
0 to 5ScopeAsk: shared or single user? What is one step? Anything with outside effects? Document size? Write the answers down.
5 to 12The modelSnapshots versus operations, with the memory numbers for 5,000 shapes. Pick operations with inverses; mention Immer patches.
12 to 18The stacks and groupingDraw past, future, and the document. Show a drag as one transaction and typing coalesced at 500 ms.
18 to 28Multiplayer and the serverLocal undo only. Undo is a normal edit through the outbox. Stale inverses and how they're rebased. What happens on rejection.
28 to 36Architecture and APIDraw the module, the adapter, the store, and the outbox. Write the History interface.
36 to 45Keys, accessibility, limits, testingPlatform shortcuts, native fields, live region, the two limits, the property test.

A strong opening sounds like this: "Before I draw anything: is this document shared, and do edits save to a server? If yes, I'll store operations with inverses rather than snapshots, undo only the local user's steps, and treat every undo as a normal edit to the server. Let me start with the stacks and then show how a drag becomes one step." In three sentences you've shown that you know the two hardest constraints and have a plan for the next 40 minutes.

Interviewers for a hard feature question often push on one area until you reach the edge of what you know. Expect them to pick the multiplayer case or the server rejection case. Stay concrete: name the card, the two users, the positions, and what each client shows at each moment. A worked example beats a general claim every time.

Follow-up questions to expect

Think first

"How is this different from version history?"

Think first

"The user goes offline, makes ten edits, undoes three, and reconnects. What happens?"

Think first

"A plugin runs a script that makes 3,000 changes. How should undo treat it?"

Common mistakes

  • Starting with structuredClone snapshots and never revisiting them once the document size and collaborators come up.
  • Recording every pointermove or keystroke as a step.
  • Treating undo as local-only, so the next reload brings the change back.
  • Global undo in a shared document, or stale inverses that overwrite a teammate's work.
  • Taking over Cmd+Z inside native text fields.
  • Putting outside effects such as emails on the undo stack.
  • No limit on history, or a limit on entries only.

Check your understanding · Exercise 13

You have 5 minutes left and haven't covered multiplayer. What's the best use of the time? Select all that apply.

Summary

  • Store operations with their inverses, not snapshots: about 300 bytes per step instead of 1.5 MB, sendable to the server, and adjustable when teammates edit. Let Immer's produceWithPatches generate inverses instead of hand-writing them.
  • History is past and future stacks around the live document. A new local edit clears redo; selection, view changes, and remote edits never do. Discard branches, and let server-side version history be the safety net.
  • One step per intention: explicit transactions for gestures and commands, 500 ms coalescing with word boundaries for typing, and a cancel that records nothing.
  • Only document state is undoable. Selection is restored with steps, never a step. Outside effects get a delayed commit with an undo toast, or an honestly named compensating action.
  • Bound history by entries and by bytes, drop the oldest, store references instead of payloads, and keep entries as plain data so nothing leaks.
  • Undo is a normal edit: inverse ops go through the same ordered outbox, the server can reject them, and rejected entries are dropped with a message. Optimistic rollbacks are never history entries.
  • In multiplayer, undo only your own steps, and keep them valid: rebase positions against remote ops (or use CRDT ids), and skip what someone else has deleted or changed since.
  • Route keys by platform and focus: native fields keep their own undo until they commit, the editor owns undo elsewhere, buttons name the step, and a live region announces it.

Check your answer

Tick what your own design covered. Pick the level you're interviewing for: each level includes the ones before it.

Junior 0 of 4
Mid-level 0 of 5

References

Concepts behind it

Try next