Engineering Atlas › Low-Level Design

Memento pattern

14 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

The problem

A level editor for a 2D game holds a world split into 256 regions. Designers place tiles, scripts and spawn points, and they want three things: undo for the last few minutes, named checkpoints ("before boss rework"), and a "playtest from here" button that runs the game and then puts the world back exactly as it was. Some operations are easy to invert (move a tile), but many are not: a procedural "fill with forest" brush touches thousands of tiles with random choices, and a script compiler rewrites internal indexes. Writing a correct inverse for each is a project in itself.

The shortcut is to let the history copy the world's fields itself. Then the history knows the world's private layout, and any code holding it can edit a saved state.

The three roles

Memento keeps the knowledge of the layout inside the object that owns it:

class World:
    def snapshot(self, label):
        return Snapshot(self, tuple(self._regions), label)   # originator writes it
    def restore(self, snap):
        if snap._owner is not self:
            raise ValueError("foreign snapshot")
        self._regions = list(snap._regions)                  # originator reads it

class CheckpointStore:          # caretaker: keeps, labels, evicts, never looks inside
    def save(self, snap): ...

The caretaker sees a narrow interface: a label, a version, perhaps a size for a memory budget. The originator sees everything. C++ does this with friend, Java with a nested class, Go with unexported fields in one package, TypeScript with #private fields, and Python by the leading-underscore convention.

A snapshot must not alias live state

The most common memento bug is storing a reference instead of a value. If snapshot() returns the live list, the next edit changes the "saved" state and undo silently does nothing. The mirror bug is restore() installing the snapshot's list directly, so the next edit mutates the memento and restoring the same checkpoint twice gives two different worlds. Both snapshot and restore copy the container.

Structural sharing

Copying the whole world on every snapshot is correct and expensive. If the world is 256 regions of 64 KB each (16 MB) and the editor snapshots every 2 seconds for a 10-minute undo window, that is 300 snapshots and 4.8 GB. But a designer working for 2 seconds touches one or two regions. If regions are immutable values and an edit replaces a region with a new one, a snapshot only has to copy the table of 256 pointers (2 KB on a 64-bit machine). Every unchanged region is shared by every snapshot that saw it. The 300 snapshots then cost 600 KB of tables plus the regions actually edited, typically tens of megabytes instead of gigabytes.

Redux's undo history works the same way: past, present and future arrays of states, where reducers return new objects only along the changed path. Clojure's persistent vectors are 32-way trees, so changing one element of a million copies about four nodes and shares the rest.

Bounding the caretaker

A history without a bound is a memory leak with an undo button. Real caretakers bound by count (the last 100 states), by time (the last 10 minutes), by bytes (500 MB), or by some mix, and they protect a few snapshots from eviction: named checkpoints, the last autosave, the state a playtest must return to. With structural sharing, "bytes" means distinct bytes: evicting the oldest snapshot frees only the regions no remaining snapshot references, so the caretaker needs per-snapshot size metadata (piece IDs and sizes), not the content. That metadata is the right amount of leakage from the memento.

Memento versus Command

Command stores the edit and its inverse; Memento stores the state before. Commands are small, but every operation needs a correct inverse. Mementos need no inverse logic, but cost memory and capture everything, including things you did not want rolled back, such as a cursor position. Serious editors combine them: commands for fine-grained undo, periodic mementos as checkpoints so recovery replays only a short tail. Databases do the same with a write-ahead log plus checkpoints.

Reviewing agent-written code

Asked to "add undo", an agent usually writes self.history.append(copy.deepcopy(self.state)) on every change, or appends without a copy. Ask three questions in review. Does a test edit the object after a snapshot and then restore? How big is one snapshot of the largest realistic state, and what bounds the history? Can code outside the originator modify a snapshot? The fix is usually small: immutable pieces, a pointer-table snapshot, and a bounded caretaker.

The same shape elsewhere

Memento is the multi-step consistency shape: save a known-good point so a failed sequence of steps can be rolled back as a unit. Database savepoints (ROLLBACK TO s1), VM snapshots before an upgrade and kubectl rollout undo all do it. ZFS and Btrfs snapshots are copy-on-write trees, created in constant time and costing nothing until blocks diverge: structural sharing on disk.

Down the stack

Layer 1The contract

The originator promises: restore(snapshot(x)) returns the object to a state observably equal to the state when the snapshot was taken, no matter what happened in between, and restoring the same snapshot twice gives the same result. A memento is immutable and opaque: no code outside the originator can change what it will restore. Restoring a snapshot from a different originator, or from an incompatible version, fails without changing the object. The caretaker promises a bound (count, bytes, age) and an eviction order, and documents which snapshots are pinned.

Layer 2The mechanism

A pointer-table memento needs two things from the state: it is split into pieces, and pieces are never mutated in place. An edit allocates a new piece and swaps the pointer in the live table; the snapshot copies the table. Persistent data structures generalise this to trees. A hash array mapped trie (HAMT), used by Clojure, Scala and Immutable.js maps, branches 32 ways using 5 bits of the key's hash per level, with a 32-bit bitmap per node recording which children exist. Updating one key copies the nodes on one root-to-leaf path, so a snapshot of a million-entry map is one root pointer, and an edit afterwards costs about four node copies. Immer gets the same effect with JavaScript Proxy objects: the draft records which nested objects were written, and only those and their ancestors are copied when the producer finishes.

The operating system offers a memento for a whole process. Redis's BGSAVE calls fork(): the child gets the parent's address space as copy-on-write pages, serialises a frozen view of the dataset to an RDB file, and the parent keeps serving writes. Only pages written after the fork are physically copied.

Layer 3The machine

A full copy is bounded by memory bandwidth: memcpy runs at roughly 10 to 20 GB/s per core, so copying 16 MB takes about 1 ms, fine once and painful every keystroke. Object-graph copies are far slower than raw bytes. CPython's copy.deepcopy visits every object, keeps a memo dict keyed by id() to preserve shared references and cycles, and costs on the order of 1 to 3 microseconds per object, so a state with 200,000 small objects takes about half a second. JavaScript's structuredClone is similar in spirit and also walks the graph. A pointer-table snapshot of 256 regions is a 2 KB allocation and a copy that fits in L1 cache: well under a microsecond.

Go has a trap here. Copying a struct copies its fields, but slices and maps are headers that point at shared backing storage. snap := *w shares the regions slice's backing array with the live world, so a later w.regions[0] = r changes the snapshot. A correct Go memento uses slices.Clone or copy for slices and maps.Clone for maps, and the pieces themselves must be immutable or cloned.

The fork() memento is paid in page tables and copy-on-write faults. With 4 KB pages, each GB of resident memory needs 2 MB of page-table entries, copied at fork time. After that, each page the parent writes takes a fault and a 4 KB copy, so a write-heavy Redis can nearly double its memory during a save.

Why it's built this way

  1. Saving state should not require the saver to know the layout, or the layout leaks into every caller, so the owner of the state creates and reads snapshots.
  2. A snapshot that can be modified, or that aliases live state, makes restore wrong, so mementos are immutable and opaque.
  3. Full copies cost memory and bandwidth proportional to the whole state while edits touch a small part, so state is split into immutable pieces that snapshots share.
  4. Even shared snapshots accumulate, so the caretaker enforces a bound, measured in distinct pieces or bytes, and pins the snapshots that matter.
  5. Restoring a whole state also restores things you wanted to keep, so fine-grained undo often uses commands with mementos as periodic checkpoints.

Diagram

sequenceDiagram
  participant E as Editor UI
  participant W as World (originator)
  participant C as CheckpointStore (caretaker)
  E->>W: set(region 7, forest)
  W->>W: new piece #812, swap pointer
  E->>W: snapshot("before boss")
  W-->>E: Snapshot(table of 256 refs)
  E->>C: save(snapshot)
  C->>C: count distinct piece ids
  C->>C: evict oldest while over budget
  E->>C: find("before boss")
  C-->>E: Snapshot
  E->>W: restore(snapshot)
  W->>W: check owner, copy table

Worked example

A web-based level editor kept undo as a list of full JSON copies of the world, taken after every brush stroke. A medium level serialised to 9 MB, designers made about 25 strokes a minute, and the history held 200 entries: 1.8 GB in the worst case. Browser tabs crashed on 8 GB laptops after about an hour, and each stroke spent 70 ms in JSON.parse(JSON.stringify(world)), which designers felt as a stutter.

The team split the world into 512 regions of about 18 KB each, made regions immutable (an edit produces a new region object), and made a snapshot a frozen array of 512 region references with a monotonic ID per region. The history became a caretaker with two bounds: 200 snapshots and 300 MB of distinct regions, computed from the set of region IDs across retained snapshots with each region's size stored beside it. Named checkpoints and the pre-playtest snapshot were pinned.

A typical stroke touches 1 to 3 regions, so 200 snapshots retained about 450 regions beyond the base 512: under 20 MB in total against 1.8 GB before, and 4 microseconds per snapshot instead of 70 ms. The one regression was "regenerate terrain", which replaces every region and evicted most of the history; the fix was a warning and a pinned snapshot before the operation.

Trade-offs

OptionStrengthsWeaknessesChoose when
Full-copy mementoSimple; always correct if the copy is deepMemory and time proportional to whole stateSmall state, infrequent snapshots
Pointer-table memento with immutable piecesSnapshot is O(pieces); unchanged pieces sharedState must be split and pieces kept immutableEditors, games, large documents
Persistent data structures (HAMT, persistent vector)O(log n) edits; snapshot is one pointerSlower reads and writes than mutable structures; allocation churnFunctional state stores, Redux-style apps
Inverse commandsMemory proportional to editsEvery operation needs a correct inverseFine-grained undo with well-defined operations
Log plus periodic checkpointsBounded recovery time and bounded memoryTwo mechanisms to build and testDatabases, collaborative editors, long sessions

Pitfalls in production

Leadership lens

Check yourself

  1. Why must the originator, not the caretaker, create and read the snapshot?

    Show answer

    Only the originator knows its internal layout and invariants. If the caretaker copied fields directly, the layout would leak into it, and any change to the originator would need matching changes in every caretaker.

  2. A snapshot copies a list of 256 region references. What property of the regions makes that a correct snapshot rather than an alias?

    Show answer

    Regions must be immutable, so an edit replaces a reference in the live list instead of changing a region shared with the snapshot.

  3. With structural sharing, why does evicting the oldest snapshot sometimes free nothing?

    Show answer

    Every piece it references may still be referenced by newer snapshots, so memory is freed only for pieces that no retained snapshot uses.

  4. When would you combine mementos with commands instead of picking one?

    Show answer

    When you want small, fine-grained undo but also bounded recovery or replay time: commands for each edit, periodic mementos as checkpoints so a restore replays only the commands since the last one.

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →