Travels' Own Benchmark Undercuts the Patch-Based Undo Pitch
Snapshots win on memory and speed in-tab; JSON patches win the moment undo history has to be persisted or replayed.
Every undo/redo pitch starts with the same arithmetic: a 1 MB document, 100 edits, 100 MB of history. Travels, the framework-agnostic undo core built on Mutative, opens with exactly that line. It's a good line. It's also mostly wrong for the apps that would reach for the library, and the project's own benchmark is what proves it. That benchmark is the most useful thing to come out of the 2.x release cycle, because it replaces a marketing claim with a real answer to the question that matters: when do JSON patches actually beat snapshots?
Patch-based undo isn't new, but making it the default is
Storing diffs instead of copies is old: it's how Immer's produceWithPatches has worked for years, and MobX-State-Tree's UndoManager has recorded JSON Patch (RFC 6902) since before hooks existed. What those tools never did was ship a history stack around it. You got forward and inverse patches and were left to build the cursor, the bounded buffer, the batching, and the persistence format yourself. Redux-undo and Zundo went the other way: a finished history stack, but each entry is a reference to a whole state tree.
Travels' bet is that the missing product is the stack itself, with patches as the only representation. createTravels(initialState) gives you setState (draft-style, via Mutative), back, forward, go, archive for coalescing several updates into one undo step, and serialize/deserialize for the history. Version 2.0 (July 18, 2026) hardened that contract — Map and Set were dropped so state is strictly JSON, subscribers get one frozen event object with event-local deltas instead of a clone of the full history, and transactions became O(1) to roll back. Version 2.1 added createTravelJournal() for runtimes that already own state and produce their own patch pairs; 2.2 (July 26) added stable warning codes and bumped the floor to Node 20. The npm line is now at 2.2.0, which is why the "1.3" and "2.1" labels floating around aggregators are both stale.
What the numbers actually say
Here's the part worth reading twice. The 2.1 release swapped "simulated comparison claims" for a reproducible run against real libraries: a ~100 KB nested document, 100 edits each touching two root fields, 50 undos then 50 redos, 21 measured rounds on Node 24. Medians from the checked-in results file:
| Library | Retained heap | Update | Undo | Serialized history |
|---|---|---|---|---|
| Travels (immutable) | 0.071 MB | 0.50 ms | 0.26 ms | 117 KB |
| Travels (mutable) | 0.071 MB | 0.54 ms | 0.08 ms | 117 KB |
| Redux-undo 1.1 | 0.010 MB | 0.07 ms | 0.04 ms | 9,842 KB |
| Zundo 2.3 | 0.015 MB | 0.12 ms | 0.05 ms | 9,746 KB |
| MST + UndoManager | 0.217 MB | 10.4 ms | 4.3 ms | 119 KB |
| mobx-keystone | 3.137 MB | 9.5 ms | 5.6 ms | 127 KB |
Look at the heap column. The snapshot libraries win — by 5–7×. That's not a fluke; it's structural sharing. When your state is an immutable tree and an edit changes two fields, the "snapshot" you push onto the stack shares every untouched subtree with its neighbour. A hundred entries cost a hundred new root objects plus the changed paths. The 100 MB horror story only materialises if you deep-clone on every edit, which nobody using Immer, Mutative, or Zustand is doing.
Snapshots also win on latency, and it isn't close: Redux-undo applies an undo in 36 µs against Travels' 260 µs, because pointer reassignment beats replaying an inverse patch through a draft. The 2.0 release notes are candid that small-update overhead is still ~1.3× a raw mutative.create call.
Where the arithmetic flips is the last column. Serialize a snapshot history and structural sharing evaporates: JSON.stringify has no notion of shared references, so 100 entries become 100 full copies — 9.8 MB and roughly 5.6 ms each way, versus 117 KB and about a tenth of a millisecond for the patch log. That's an 84× gap, and it's the number that decides whether you can persist history to localStorage or IndexedDB, hand it to a server, or replay it across tabs.
So who should switch
The honest rule from the data: if undo history lives and dies in one browser tab, keep your snapshot stack. Zundo is under 700 bytes, Redux-undo is boring and battle-tested, and both are faster and lighter in memory for the ordinary case. Travels' own README concedes this and says as much.
Adopt Travels when the history itself has to leave the process. Concretely:
- Persisted drafts. A design tool or editor that restores "where you were, with undo intact" after reload. A 10 MB serialize-on-every-keystroke is a non-starter; 117 KB is fine.
- Operation logs. If your product wants an audit trail or server-side replay, RFC 6902 patches are already a wire format. Snapshots aren't.
- Large state, small edits, long retention. Note the
maxHistorydefault is 10, so you'll set it explicitly to get anywhere near the "long history" scenario; the serialized cost stays proportional to the edits, not to the document.
The 2.1 controlled journal is the piece aimed at people who already have a store that emits patches — an MST adapter, a Mutative-based reducer, a CRDT bridge. You call journal.recordPatches(nextState, { patches, inversePatches }) inside your commit, and back()/forward() hand a composed transition to your apply callback instead of touching state directly. It's the first design in this space that treats the history stack as a peer of the store rather than a wrapper around it, and it's the reason the MST and Coaction rows even exist in the benchmark.
The migration friction is real: 2.0 broke the subscriber signature, banned Map/Set, and pinned mutative@^1.3.0; zustand-travel@2 requires travels@2. Three minor-or-major releases in eight days in July suggests the API is still being felt out, and the HN launch thread drew 23 points and zero comments, so there's no independent production experience to lean on yet. That's the caveat.
The real shift
The interesting move here isn't "patches use less memory." That claim doesn't survive the project's own numbers, and anyone repeating it is quoting the README rather than the benchmark directory. The genuine shift is that undo history is becoming a serialization concern — something you sync, persist, and replay — and once that's the requirement, a snapshot stack is the wrong data structure regardless of how cheap it is in RAM. Travels is the first library to build a complete stack on that premise and then publish the trade-off honestly. If you don't need the history to travel, you don't need Travels. If you do, the alternatives are hand-rolling it on Immer's patch primitives or dragging in MobX-State-Tree, and the table above shows how that goes.
Sources & further reading
- Travels: patch-based undo/redo optimized for large state, small updates, long history, and persistence — github.com
- Travels 2.0.0 release notes — github.com
- Travels 2.1.0 release notes — github.com
- Travels performance and memory benchmarks (real-library comparison) — github.com
- Controlled Journal Guide — github.com
- Rethinking Undo/Redo - Why We Need Travels — dev.to
- zundo: undo/redo middleware for zustand — github.com
- Travels: Fast framework-agnostic undo/redo powered by mutative JSON patch — news.ycombinator.com
Mariana covers the fast-moving world of machine learning and generative AI, with a particular focus on how these technologies are reshaping development workflows. When she isn't stress-testing the latest foundation models, she's usually at a local hackathon.
Discussion 0
No comments yet
Be the first to weigh in.