Frontend System Design: Undo / Redo (Photoshop-Style History)

interviewSeptember 21, 2026Β· 8 min read

"Design undo/redo" sounds like a data-structures warm-up: two stacks, push on change, pop on Ctrl+Z. Five minutes on a whiteboard. That's the tutorial version. The production version β€” the one Photoshop, Figma, and Google Docs actually ship β€” is a memory-management problem wearing a keyboard shortcut's clothing: a canvas with 40,000 objects where every "cheap" full snapshot costs 80MB, a paragraph of typing that must undo as one action instead of 47, a network save that's mid-flight when the user hits undo, and two people editing the same document where "undo" has to mean my last change, not the world's.

The two-stack answer isn't wrong β€” it's just the answer to a question nobody asks in production. Let's design the real one.

Requirements β€” the questions that split junior from senior

Before drawing anything, ask:

  • What granularity? Per keystroke? Per action? Per drag gesture? In Figma, typing a sentence is one undo entry; moving a shape is one entry even if it fired 120 pointer events.
  • Single-user or collaborative? Google Docs' undo removes your last edit, even if a teammate typed after you. A global history stack is wrong there.
  • How big is the state? A login form is 200 bytes. A design-tool canvas can be 500MB. The architecture that's perfect for one is fatal for the other.
  • What's the memory budget? Photoshop caps history at ~50 states by default and lets users trade depth for RAM. History is a cache, not an archive.
  • Is everything undoable? Navigation, file saves, sent emails, payments β€” usually not. "Undo send" in Gmail is a delay, not an inverse.

The answers pick your architecture for you. There are exactly two that matter.

The naive answer β€” and exactly where it dies

// The answer everyone gives first
const past = [], future = [];

function set(next) {
  past.push(JSON.parse(JSON.stringify(current))); // full copy
  future = [];                                     // new action kills redo
  current = next;
}

function undo() {
  if (past.length === 0) return;
  future.push(current);
  current = past.pop();
}

This passes a phone screen and dies in production on three counts:

  1. Memory. A deep copy per action on an 80MB canvas means 50 undo steps = 4GB. The browser tab dies long before the user runs out of regrets.
  2. Granularity. Wire this to an onChange and typing "hello" becomes five undo steps. The user hits Ctrl+Z once and gets "hell". Instantly feels broken.
  3. Side effects. Popping a stack restores client state. It does not un-send the HTTP request that already happened. Undo is a state problem and an effects problem, and the naive answer only solves the first.

Each of these has a known, nameable fix. That's what the rest of this design is.

Architecture option A β€” the Command pattern (store inverses)

Instead of storing state, store operations that know how to reverse themselves:

const actions = {
  moveShape: {
    do(doc, { id, dx, dy })  { return move(doc, id, dx, dy); },
    undo(doc, { id, dx, dy }) { return move(doc, id, -dx, -dy); },
  },
  addShape: {
    do(doc, { shape })    { return insert(doc, shape); },
    undo(doc, { shape })  { return remove(doc, shape.id); },
  },
};

Memory cost is tiny β€” a few numbers per entry, so 1,000 steps of history cost nothing. This is the right call for small, well-defined, synchronous mutations on huge state: moving shapes, toggling layers, reordering list items.

But it has a sharp edge: every action needs a hand-written, exactly-correct inverse, and inverses get fragile the moment effects are async or depend on server state. "Resize shape" inverts cleanly. "Submit form" does not. Teams that go all-in on commands end up maintaining two implementations of every feature β€” the feature and its rewind β€” and they drift apart silently. The undo bug reports write themselves.

Architecture option B β€” immutable snapshots + structural sharing

The alternative looks identical to the naive answer and is completely different under the hood:

// undoable(): a higher-order reducer, the Redux-classic pattern
const LIMIT = 100; // history depth cap

function undoable(reducer) {
  const initial = {
    past: [],
    present: reducer(undefined, {}),
    future: [],
  };

  return function (state = initial, action) {
    const { past, present, future } = state;

    switch (action.type) {
      case "UNDO": {
        if (past.length === 0) return state;
        return {
          past: past.slice(0, -1),
          present: past[past.length - 1],
          future: [present, ...future],
        };
      }
      case "REDO": {
        if (future.length === 0) return state;
        return {
          past: [...past, present],
          present: future[0],
          future: future.slice(1),
        };
      }
      default: {
        const next = reducer(present, action);
        if (next === present) return state; // no-op: never pollute history
        const trimmed =
          past.length >= LIMIT ? past.slice(1) : past; // LRU: drop oldest
        return { past: [...trimmed, present], present: next, future: [] };
      }
    }
  };
}

The magic line is if (next === present) return state. With immutable updates and structural sharing, changing one deep node copies only the path from the root to that node and reuses every other object by reference. So each entry in past doesn't cost "one full document" β€” it costs roughly "one changed path". An 80MB canvas where the user moved one shape costs bytes per undo step, not megabytes.

This is why Figma can offer 100+ undo steps on giant files, and why the undo feature in most Redux apps is nearly free: the snapshots were already being created by the architecture itself.

Pick B as your default answer. Use A inside it for hot paths where even a changed path is heavy (per-pixel painting). Photoshop famously mixes both. In an interview, saying that sentence is the senior moment.

Coalescing β€” 47 keystrokes, one undo entry

The granularity requirement is solved by coalescing: merging consecutive actions into one history entry before they ever reach past.

function shouldCoalesce(prev, next) {
  return (
    prev.type === "TEXT_INPUT" &&
    next.type === "TEXT_INPUT" &&
    prev.blockId === next.blockId &&   // same target…
    next.op === prev.op &&             // …same operation…
    next.t - prev.t < 500              // …within the time window
  );
}

// in the reducer, before pushing to past:
if (past.length && shouldCoalesce(past[past.length - 1], next)) {
  // merge: mutate present only, do NOT push a new past entry
  return { ...state, present: reducer(present, action) };
}

Three rules make coalescing feel right instead of weird:

  • Time window (~300–500ms): typing pauses end an entry.
  • Delimiters commit: space, enter, and blur flush the current entry immediately.
  • Gestures, not events: a drag from pointerdown to pointerup is one entry, no matter how many pointermove events fired. Commit history on pointerup.

Get this wrong and undo feels broken in a way users can't articulate β€” they just say the app is "janky around Ctrl+Z".

A timeline, not two stacks

Once you add a history panel (Photoshop's history list) or collaborative editing, representing history as two stacks gets awkward. Model it as a single append-only list plus a cursor:

// commit truncates the redo branch β€” this IS the "new action clears redo" rule
function commit({ entries, index }, entry) {
  return {
    entries: [...entries.slice(0, index + 1), entry],
    index: index + 1,
  };
}

function undo(timeline)   { return { ...timeline, index: Math.max(0, timeline.index - 1) }; }
function redo(timeline)   { return { ...timeline, index: Math.min(timeline.entries.length - 1, timeline.index + 1) }; }
function canUndo(t) { return t.index > 0; }
function canRedo(t) { return t.index < t.entries.length - 1; }

canUndo/canRedo now directly drive disabled toolbar buttons and menu items β€” grey them out, never silently no-op β€” and the history panel is just entries with index highlighted. One model, three UI surfaces.

The effects problem β€” what undo must not touch

Restoring state is only half the contract. Classify every action explicitly:

  • undoable: local β€” pure client state (drag, resize, edit text). Snapshot handles it.
  • undoable: compensating β€” server state that can be reversed: moving a card issues a PATCH on do and another PATCH on undo. Undo = restore client state + fire the compensating request (idempotently, see below).
  • not undoable β€” navigation, saves, payments, sending messages. These still truncate the redo branch but never appear in history. "Undo send" is implemented as a 5-second hold, not an inverse β€” the email simply hasn't left yet. When interviewers ask about Gmail's undo send, that's the answer.

Race conditions β€” undo during an in-flight save

The user hits Ctrl+Z 100ms after a save request left. Now what?

  • Version stamp every write. The PATCH carries expectedVersion; if the server moved on, reject and surface a conflict instead of silently clobbering.
  • Abort in-flight work on undo. If an optimistic operation is still pending, abort it (AbortController) or supersede it; otherwise its resolution will land later and "re-apply" the very change the user just undid β€” the classic ghost-edit bug.
  • Idempotent compensations. A compensating PATCH that gets retried on flaky networks must be safe to run twice: key it by clientOpId.

Collaborative undo β€” "my" last change, not "the world's"

In a multi-user document, a single shared history stack means one user's Ctrl+Z can revert a teammate's work β€” instant rage-quit territory. The production answer:

  • The server stamps every operation with a monotonic opId and the author's userId.
  • Each client keeps a per-user timeline. Undo targets your highest-opId entry.
  • In OT/CRDT systems, undo is itself an operation β€” it doesn't delete history, it appends an inverse that merges like any other edit. Late-joining peers replay it like any other op.

You don't need to derive operational transforms in the interview. You need to say: global history stacks are wrong in multi-user contexts; undo becomes per-author and expressed as new operations the same way normal edits are.

UI details interviewers actually probe

  • Shortcuts: Cmd/Ctrl+Z, Cmd/Ctrl+Shift+Z, and Ctrl+Y (Windows alias). Inside a contentEditable or input, the browser has its own native undo stack that will fight yours β€” intercept keydown (and know about beforeinput's historyUndo/historyRedo), or scope your history to app state outside the text surface and let the browser own the text.
  • Announce undo actions via an aria-live="polite" region: "Undid: Move shape". A screen-reader user otherwise sees the world silently change.
  • Toast with a Redo button after undo (and a timed "Undo?" toast after destructive actions) β€” the affordance that makes undo discoverable for users who never touch keyboard shortcuts.

Interview gotchas β€” quick fire

  • Typing that undoes one character at a time β†’ you forgot coalescing.
  • 4GB tab on a large canvas β†’ you deep-copied instead of relying on structural sharing; or cap the history.
  • Redo resurrecting after a new action β†’ you didn't truncate the branch on commit.
  • No-op actions (polling ticks, focus events) creating phantom history entries β†’ you push to past even when present === next.
  • Undo "working" but the server re-asserting the change 2s later β†’ in-flight op wasn't aborted or version-stamped.
  • Multi-user Ctrl+Z nuking a teammate's paragraph β†’ global stack instead of per-author timelines.
  • Losing history on refresh β†’ persist the timeline (it serializes fine β€” it's just state) to sessionStorage on pagehide.

Putting it together

A production-grade undo/redo design:

  • Model: a single timeline (entries + cursor) wrapped around your state via a higher-order reducer β€” not two stacks
  • Storage: immutable snapshots with structural sharing (bytes per step, not megabytes); commands-with-inverses for heavy hot paths; both is a fine answer
  • Granularity: coalesce text by time window and delimiters, gestures by pointerdownβ†’pointerup
  • Bounds: capped history (LRU eviction of the oldest entry) with a user-facing depth/memory trade-off
  • Effects: classify actions local / compensating / not-undoable; version-stamp and abort in-flight writes on undo; "undo send" = a 5s hold
  • Multi-user: per-author timelines; undo expressed as a new operation, never history deletion
  • UI: disabled states from canUndo/canRedo, live-region announcements, redo toasts, and a history panel rendered straight off the timeline

The stack is two lines; the product is a memory-bounded, coalesced, race-safe, per-author timeline. That gap is why it's asked.

The Series: Frontend System Design Interview Questions

Donate