A browser-based time-travel debugger and observability viewer for LLM agent runs - think Chrome DevTools Performance panel × a video-editor timeline, for agent traces.
Drop in a trace and scrub through it like a video: watch tool calls and LLM steps fire on a Canvas waterfall, replay token streams as they type out, inspect any span's real data, and diff two runs side by side. The hard part - and the point - is that the timeline renders 50,000+ spans at 60fps with smooth pan, zoom, and scrub.
Live demo: https://agent-replay.onrender.com · Writeup: How I rendered 50k agent spans at 60fps
Trace viewers, eval harnesses, and agent debuggers are the internal tooling every AI lab builds. Rendering tens of thousands of spans at 60fps with smooth interaction is a genuine frontend engineering problem - canvas, culling, level-of-detail, a decoupled animation loop, off-main-thread parsing - the kind of thing that can't be faked and that a reviewer can verify by opening DevTools.
- Canvas waterfall timeline - nested spans colored by kind on depth rows, a time axis with "nice" ticks, drag-to-pan and wheel zoom-about-cursor, hover, and click-to-select.
- 60fps at 50k spans via structure-of-arrays geometry, viewport culling, and level-of-detail density bands (see How it works).
- Time travel - a playhead you can scrub on the ruler or play back (0.25×-8×), with the "world
at T" (the live call stack) highlighted. Full keyboard transport (space, ←/→,
[/]). - Token replay - the selected LLM span's response types out in sync with the playhead, with a tokens/sec readout and an arrival-time sparkline.
- Inspector - tabs (Overview, Messages, Input, Output, Attributes, Raw) driven entirely by real per-span data.
- Minimap - a cached full-trace overview with a draggable viewport window and a playhead.
- Virtualized span tree - a keyboard-navigable, ARIA
treethat doubles as the accessible fallback for the canvas, synced both ways with selection. - Diff view - align two runs by span path, classify added / removed / changed, and see per-span timing deltas plus a Δ duration / tokens / cost / spans summary.
- Search & filter - by kind, status, and free text; matches stay bright while the rest dims.
The timeline is drawn on a single <canvas>, never DOM (DOM dies past ~10k nodes). The techniques
that get it to 60fps at 50k spans:
- Structure-of-arrays geometry. Span geometry lives in flat typed arrays (
Float32Arrayfor start/end, small int arrays for depth/kind/status), not an array of objects. The render loop iterates 50k spans per frame cache-friendly and GC-free. Heavy payloads (inputs/outputs, messages) sit in a separate lazy store keyed by index, so the hot path never pulls them into cache. - Viewport culling in
O(log n + k). A max-end segment tree over the start-sorted spans answers "which spans overlap[t0, t1]?" (and "active at time T?") without scanning all of them. - Level of detail. When a bar would render narrower than ~2px, it isn't drawn individually; it accumulates into a per-row, per-column density buffer rendered as a heat band (opacity by count, colour by the most salient kind). This caps draw calls at ~rows×cols regardless of span count, so a zoomed-out 50k trace reads as shape, not mush.
- A decoupled animation loop. One
requestAnimationFrameloop owns the canvas; the viewport, playhead, hover, and drag state live in refs, never React state, so interaction never triggers a React render. The loop is on-demand - a frame is scheduled only while something is dirty, so the page idles when nothing moves. React panels update on settle (selection), not per frame. - Off-main-thread ingest. A Comlink Web Worker parses the JSONL and builds the index, then transfers the typed-array buffers back to the main thread zero-copy (transferables), so a multi-MB trace never blocks the UI.
Render time per frame, worst case (fully zoomed out, every span processed), 1440px canvas, dpr 1:
| spans | draw / frame | of the 16.6ms 60fps budget |
|---|---|---|
| 1,500 | 0.6 ms | 3.7% |
| 10,000 | 5.0 ms | 29.9% |
| 50,000 | 6.9 ms | 41.4% |
Ingest (parse JSONL + build index), median of 5 runs on Node 22 (npm run bench):
| spans | JSONL | parse | index | total |
|---|---|---|---|---|
| 1,000 | 1.0 MB | 6.6 ms | 1.5 ms | 8.2 ms |
| 10,000 | 10.4 MB | 56.0 ms | 11.6 ms | 67.6 ms |
| 50,000 | 52.2 MB | 302.1 ms | 53.8 ms | 355.9 ms |
| 100,000 | 104.1 MB | 861.0 ms | 165.9 ms | 1026.9 ms |
OpenTelemetry-flavored: a Run with nested Spans (each with kind, timing, status, and a parent
link) plus point-in-time SpanEvents, with LLM-specific extras (messages, usage, a token stream)
aligned loosely with the OpenTelemetry GenAI semantic conventions. Traces are ingested as JSONL
(a run header line, then span lines, then event lines) so large traces stream and parse
incrementally. The full model is in src/types/trace.ts.
Requires Node ≥ 22 (Vite 8 / Rolldown use a Node 22+ API; .nvmrc pins it).
npm install
npm run dev # http://localhost:5173| Command | Description |
|---|---|
npm run dev |
Vite dev server |
npm run build |
Type-check (tsc -b) + production build |
npm run preview |
Preview the production build |
npm run typecheck |
Type-check only |
npm run lint / npm run format |
ESLint / Prettier |
npm run test / npm run test:run |
Tests (watch / once) |
npm run gen -- <size> |
Emit synthetic JSONL (small/medium/large/xl) |
npm run bench |
Benchmark parse + index across sizes |
src/
components/ React UI: TimelineCanvas, Minimap, SpanTree, Inspector, TokenReplay, Transport, DiffView
render/ Canvas renderer + non-React controllers: TimelineRenderer, viewport/playhead controllers, hitTest
lib/ Pure logic: traceIndex, viewport, ticks, parse, syntheticTrace, diff, filter, perturbTrace, bench
workers/ ingest.worker.ts - Comlink worker that parses + indexes off the main thread
store/ Zustand store
types/ Trace data model
fixtures/ Curated demo traces
scripts/ gen-trace, bench-ingest
All pure logic in lib/ and render/ is unit-tested (Vitest); the interval queries are checked
against a brute-force scan. The canvas/worker glue is verified in the browser.
React 19 · TypeScript · Vite 8 · Canvas 2D · Web Workers (Comlink) · Zustand · TanStack Virtual · Tailwind CSS v4 · Vitest.
Static SPA - any static host works. Configured for Render (render.yaml): create a Blueprint
from this repo, or a Static Site with build npm ci && npm run build and publish dir dist. A thin
Node/Hono + SQLite + WebSocket backend (live mode) is planned and will live alongside on Render.
Built Agent Replay, a browser-based time-travel debugger for LLM agent runs (React/TypeScript, Canvas, Web Workers): renders 50k+ trace spans at 60fps via structure-of-arrays geometry, viewport culling, and level-of-detail aggregation, with scrubbable token-level replay, two-run diffing, and off-main-thread parsing/indexing.
