Journal

May 29, 2026

Scrubbing a Graph Through Time

Building a temporal graph data structure, and why its most useful feature is the part it refuses to do.

  • TypeScript
  • Data Structures
  • Temporal Graph
  • Event Sourcing
  • Graph Visualization
  • Library Design
  • API Design
  • Open Source
Scrubbing a Graph Through Time

Scrubbing a graph through time

A recurring need in the graph interfaces I work on is to see not just what the graph looks like now, but what it looked like at some earlier point. I want to drag a cursor back and forth across that history and watch the structure change underneath it, Temporal scrubbing. The rendering side of that is its own problem, and a harder one. This post is about the layer underneath: the data structure that has to answer "what did this node or edge look like at snapshot N" cheaply and correctly while the scrub-head is moving. That became temporal97, which now has a proper docs site.

The interesting part of the project wasn't the time-travel itself. Once you commit to the right model, advance(), rewind(), and seekTo() are close to mechanical. The interesting part was deciding what the library should refuse to do.

Record changes, not states

The naive version stores a full graph snapshot per time step. That's fine for ten steps and terrible for ten thousand. The model that scales is to store an immutable log of mutations. Any point-in-time view is then just the result of replaying that log from the start up to a cursor. append() takes a batch of set/delete operations keyed to a snapshot; the cursor methods move through them, and the current view is materialized as you go. The full vocabulary (snapshot, mutation, entry, cursor, and delta) is laid out in the core concepts docs.

graph.append({
  snapshot: 1,
  mutations: [
    {
      kind: "edge",
      op: "set",
      id: "a->b",
      value: { source: "a", target: "b", weight: 5 },
    },
  ],
})

graph.getEdge("a->b") // current value at the cursor
graph.getEdgeAt("a->b", 0) // value at snapshot 0, cursor untouched

The decision in there that paid off most was making getEdgeAt leave the cursor alone. Early on I had a single notion of "current time," and anything that wanted to peek at an earlier value (a tooltip hover, a diff against the previous frame) had to seek there and seek back. That coupling leaked everywhere, and it produced exactly the kind of off-by-one-frame bug that's miserable to track down. The scrub-head's position and "what was the value at time N" are two different questions, and they should never contend for the same piece of state. Splitting them removed a whole class of bugs at once.

There's a second half to that decision. Every write and every cursor move hands back a Delta: the exact set of node and edge IDs that were added, updated, or removed. The consumer never has to diff two graph states to discover what changed; the structure already knows, because it just did the change. That's what makes a smooth scrub possible: the renderer reacts to a small changeset each frame instead of re-reconciling the entire graph. And it's the same seam that lets temporal97 drive Graphology or vis-network without knowing anything about any of them. The library's whole contract with the outside world is "here is precisely what moved."

Single writer, on purpose

This is the part I'd point a reviewer at first.

temporal97 is single-writer by design. Two sources writing concurrently don't crash it. They silently produce incorrect ordering, which is the worst failure a history structure can have. A crash is honest. Quietly wrong history is not.

I could have reached for the usual machinery to handle that: vector clocks, an embedded CRDT, some internal conflict-resolution pass. I deliberately didn't. A small library that quietly grows a distributed-consensus engine inside itself is hard to reason about and impossible to test exhaustively. Worse, it solves a problem the large majority of its users (who have exactly one writer) don't have.

So the boundary is explicit, and so is the way out of it. If you have multiple writers, serialize them through one ordered log (Kafka, EventStore, a database queue) and project that single ordered stream into the graph. If you need real distributed collaboration, let a CRDT (Yjs, Automerge) own conflict resolution and feed its resolved operations in as a read model. Either way the graph stays a clean, deterministic projection of an ordered stream. Ordering becomes somebody else's job, and that somebody is far better at it than a heuristic I bolted on would be.

The general principle I keep coming back to: a small library's most valuable feature is often a clearly marked edge. "Here is exactly what I don't handle, and here is where that responsibility belongs" is more useful to an engineer evaluating the dependency than another feature would be. It's the difference between a tool you can reason about and a tool you have to reverse-engineer the first time it surprises you.

What it's deliberately not

  • Not a database. It serializes (export() the log, import() it back), but there's no query engine, no durability, no transactions. Persist the log somewhere real and rebuild the graph from it.
  • Not a sync engine. See above. That lives one layer up.
  • Not a renderer. It answers "what was true at time N." Drawing that, smoothly, while someone drags a cursor, is the consumer's problem and the genuinely hard one. The integration guides wire the Delta into the libraries that are good at it.

What I'd revisit

Checkpointing. A seekTo costs O(K·M), proportional to the mutations between where the cursor is and where it's going. Frame-to-frame scrubbing is cheap; a long jump across a deep log pays for every step in between. The classic event-sourcing fix is to materialize a snapshot every K mutations and replay forward from the nearest one. I left it out of v0.x on purpose: I wanted the replay path provably correct before I started caching around it.

The expensive query. getEdgesForNodeAt has to scan every edge's history to decide what was connected at a given snapshot, so it gets costly on large, churny graphs. It ships with a documented workaround, but it's the one method whose shape I'd most want to revisit.


The library is small, and that's the point. The judgment in it lives in the negative space: the snapshot-replay model instead of stored states, the split between cursor position and point-in-time queries, and a single-writer boundary I chose to mark clearly rather than paper over. The rendering layer that sits on top of this (actually drawing a graph mid-scrub without dropping frames) is the next thing I want to write about.