Undo is one of those features nobody thinks about until it's wrong (Literally, like this blogger's love life...)
When you first think of it, the lazy way to build it is to photocopy the entire canvas after every change and keep the stack of copies. That works fine on a board with ten shapes. On a board with ten thousand, it falls apart — every little nudge clones the whole scene into memory. On a collaborative whiteboard, where boards get big and edits come fast, that's a nuh-uh.
So Drawdy doesn't store snapshots of the canvas. It stores what changed. This post is about how we record that, how we make each recording almost free, and how we decide where one "undo step" begins and ends.
What actually goes on the stack
Every entry in our history is a diff — a small record of the three things that can happen to the set of elements between one moment and the next. Some elements got added, some got removed, and some got updated. That's it:
export interface RecordsDiff {
added: Map<string, Element>;
removed: Map<string, Element>;
updated: Map<string, [from: Element, to: Element]>;
}
The nice thing about describing a change as a diff is that undoing it is the same diff read backwards. Whatever was added should now be removed, whatever was removed should be added back, and every update's before-and-after should trade places. We never write separate "do" and "undo" logic — we just flip the diff:
export function invertDiff(diff: RecordsDiff): RecordsDiff {
const updated = new Map<string, [Element, Element]>();
for (const [id, [from, to]] of diff.updated) {
updated.set(id, [to, from]);
}
return { added: diff.removed, removed: diff.added, updated };
}
Undo applies the inverted diff to the scene; redo applies the original. Both run through the same apply step that deletes the removed elements, adds the added ones, and swaps in the updated ones. One direction of code, two directions of time. This makes it easy for the system to reach any state of the canvas without ever referencing the whole data.
Why the recording is nearly free
Storing "what changed" only pays off if figuring out what changed is cheap — and this is where a decision we made much earlier about elements does the heavy lifting. Elements in Drawdy are immutable. You never edit one in place; when something changes, we build a brand-new element object and drop it into the scene in place of the old one:
const updated = { ...existing, ...changes, updatedAt: Date.now() };
this._elements.set(id, updated);
That one habit makes diffing almost trivial. Because a changed element is always a different object than it was before, we can spot changes by identity alone — no walking through every property comparing values. If the reference is the same, nothing happened to it; if it's different, it's an update:
for (const [id, el] of next) {
const prev = base.get(id);
if (!prev) added.set(id, el);
else if (prev !== el) updated.set(id, [prev, el]);
}
It also means a "snapshot" of the scene is just a list of the current element references — no deep copying at all:
public snapshot(): Element[] {
return Array.from(this._elements.values());
}
This is structural sharing. An element that hasn't changed in the last twenty edits is one single object, pointed at by the live scene and by every history entry from that era of the board. A diff only holds references to the handful of elements that actually moved. So the memory an undo step costs is proportional to the size of the edit, not the size of the canvas. Nudging one shape on a ten-thousand-element board records one shape.

Added and updated both hand us a fresh object — the only question is whether its id was already on the board.
Deciding where one step begins and ends
The scene keeps a version counter that ticks up on every committed change, and the history manager keeps a baseline — its picture of the last settled state of the board. When you finish something worth remembering, we compare the current scene against the baseline, and the difference between them becomes a single entry. Then the current state becomes the new baseline, and we start watching again.
const diff = computeDiff(this._baseline, current);
if (!isEmptyDiff(diff)) {
this._historyManager.push({
diff,
selectionBefore: this._baselineSelection,
selectionAfter: [...selectionAfter],
});
}
this._baseline = current;
So why doesn't a drag flood the stack? Because the frame-by-frame updates during a drag are preview updates. They change what's on screen, but they deliberately don't tick the version counter — as far as history is concerned, nothing has happened yet. Only when the drag settles does the version move, and the whole gesture collapses into one clean entry. The same mechanism quietly ignores no-ops. If the diff between the current scene and the baseline is empty, there's simply nothing to push.

Dozens of frames of movement, one entry on the stack — the whole drag comes back in a single step.
Two details make undo feel right rather than just correct. First, we capture the diff lazily — if you hit undo while a change is still "pending" against the baseline, we finalize that entry first and then undo it, so you never lose the very last thing you did to a timing quirk. Second, every entry remembers the selection both before and after the change. Undo doesn't just put your shape back; it re-selects what you had selected at the time, so the board lands exactly where your memory expects it.
Two stacks, multiplayer, and the edges
Underneath, the mechanics are the classic two-stack dance. New entries go on the past stack. Undo pops from the past and pushes onto the future; redo does the reverse. And the moment you do something new after undoing, the future is thrown away — that branch of history is gone, which is exactly what people expect:
push(entry: HistoryDiffEntry): void {
if (this._future.length) this._future = [];
this._past.push(entry);
if (this._past.length > this.maxSize) this._past.shift();
}
Multiplayer changes the game, though. Everything above describes undo when you're on a board by yourself. The second another person is editing the same canvas, a local "put the board back how it was" stack is the wrong model — you shouldn't be able to undo their work. So when a collaborative session is active, the history manager steps aside and hands undo and redo to Yjs instead (more details in collaboration). We also keep a simple switch to suspend recording temporarily, for the times we change the scene programmatically and don't want those edits landing in your personal undo stack.
One consequence of capturing changes at boundaries: where you place a boundary decides what counts as "one step." Mark them well and a whole drag undoes as one gesture. That's why every tool in Drawdy marks its boundary at the moment its gesture completes — the step size always matches what your hand just did.
None of this is visible when it works, and that's the point. You draw, you delete, you drag, you change your mind — and every time you reach for undo, the smallest possible record of what changed is already sitting there, waiting to be read backwards.