How Session Replay Works: Snapshots, Mutations, and a Lot of JSON
Session replay works by recording the structure of a web page — the DOM — and every change to it, as a stream of timestamped JSON events. There is no screen capture and no video file. When you press play, a player rebuilds the page from that data and reapplies the changes in order, like a very fast, very obedient browser rendering the past.
That one sentence answers “how does session replay work” at the level most people need. The rest of this article is for the level most engineers want: what actually fires when a user clicks a button, what the serialized event looks like, how it gets to a server without wrecking page performance, and why scrubbing backwards in the player is slower than scrubbing forwards.
How does session replay work, step by step
The recording pipeline has four stages, and every DOM-based replay tool (rrweb, and everything descended from it, including Highlight forks like LogReplay) implements some version of them:
- Full snapshot. At recording start, serialize the entire DOM into a JSON tree. Every element gets a numeric ID.
- Incremental events. From then on, record only changes: DOM mutations, mouse movement, clicks, scrolls, input values, viewport resizes.
- Batch and upload. Buffer events in memory, compress, and ship them to a server every few seconds.
- Reconstruction. The player builds the snapshot inside a sandboxed iframe and replays the incremental events against it, using those numeric IDs to find its targets.
If you’ve ever looked at how video codecs work, this shape will feel familiar: a video stream isn’t thousands of complete images, it’s occasional keyframes plus small delta frames describing what moved. Session replay is the same idea applied to a DOM tree instead of pixels. Full snapshots are keyframes. Mutations are the deltas. Almost everything interesting about replay — its small payload size, its fidelity, and its awkward seeking behavior — falls out of that design.
The full snapshot: a page as one big JSON object
When the recorder starts, it walks the DOM from document down, converting each node into a plain object: tag name, attributes, text content, children. Each node is assigned an integer ID and registered in a map (“mirror”) so later events can say “node 187 changed” instead of shipping a CSS selector and praying it still matches.
A tiny fragment of a serialized page looks like this:
{
"type": 2,
"tagName": "button",
"attributes": { "class": "btn btn-primary", "id": "checkout" },
"childNodes": [
{ "type": 3, "textContent": "Place order", "id": 188 }
],
"id": 187
}
Snapshots capture structure and text directly, reference heavy assets like images by URL, and inline stylesheet rules so the page can be styled later even if your CDN has moved on. The details of that serialization — what’s copied, what’s linked, what’s rebuilt — deserve their own article; the important point here is that the snapshot is a complete, self-describing starting state.
Snapshots are the expensive part. On a large app, one can run to a few hundred kilobytes even after compression. That’s why recorders take them rarely: once at the start, and then only when forced to (a full navigation, or a periodic “checkpoint” snapshot some tools insert precisely to make seeking cheaper — more on that below).
One click, traced end to end
Say a user clicks that “Place order” button. Here’s the actual sequence.
1. The listener fires. The recorder has a passive click listener on document. It looks up the target element in the mirror — node 187 — and emits an interaction event:
{
"type": 3,
"timestamp": 1750412345678,
"data": {
"source": 2,
"type": 2,
"id": 187,
"x": 642,
"y": 310
}
}
Numeric codes keep it compact: type: 3 means “incremental event,” source: 2 means “mouse interaction,” the inner type: 2 means “click.” Forty-odd bytes of meaning.
2. The DOM reacts, and MutationObserver sees it. Your app’s click handler disables the button and swaps its label to “Processing…”. The recorder doesn’t hook your framework to learn this — it uses MutationObserver, the browser API that reports DOM changes in batches after the current task finishes. The callback receives the changed attribute and text nodes, and the recorder serializes just the diff:
{
"type": 3,
"timestamp": 1750412345691,
"data": {
"source": 0,
"attributes": [
{ "id": 187, "attributes": { "disabled": "" } }
],
"texts": [
{ "id": 188, "value": "Processing…" }
]
}
}
This is the core trick. MutationObserver callbacks are asynchronous and batched by the browser, so recording cost scales with how much the DOM changes, not with how big the page is. A click that flips one attribute costs two tiny events. Rendering a 5,000-row table costs a lot more — which is exactly when you’d expect it to.
3. The event joins a buffer. Nothing goes over the network per-event. Events accumulate in an in-memory array alongside whatever else the SDK is capturing on the same clock — console output, network request metadata, errors — so that everything lands on one shared timeline.
4. Flush. Every few seconds (or when the buffer hits a size threshold), the batch is compressed and sent. Text-heavy JSON with repetitive keys compresses embarrassingly well; 5–10x is normal. On tab close, recorders reach for navigator.sendBeacon(), which queues a small payload the browser will deliver even after the page is gone — a plain fetch at unload time can be killed mid-flight.
Total network cost for our click: two events, maybe 150 bytes compressed, delivered seconds later. The user never notices.
Playback: running the tape forward
The player is where the JSON becomes a page again. It creates a sandboxed iframe, builds the DOM described by the full snapshot inside it, then walks the event list in timestamp order: apply this mutation, move the synthetic cursor here, set this scroll offset, update this input value.
Two things are worth knowing about what you’re watching:
- It’s a reconstruction, not a recording. The player renders real HTML and CSS in your browser. That’s why you can inspect elements in a replay with devtools — it’s a live DOM.
- It’s inert. Scripts were stripped during serialization, so nothing executes. The replayed page can’t fire your analytics, submit forms, or make requests. What ran on the user’s machine is represented only by its effects on the DOM.
Why seeking backwards is expensive
Here’s the design’s one genuine weakness. Every incremental event is a delta: it only makes sense applied on top of the state that preceded it. Playing forward is trivial — keep applying deltas. But there is no “reverse” of a mutation event. If an event says “node 188’s text is now Processing…”, nothing in that event tells you what the text was before.
So when you drag the scrubber from 4:00 back to 3:00, the player can’t rewind. It has to go back to the most recent full snapshot before 3:00 and re-apply every event from there forward — potentially thousands of mutations, replayed silently at maximum speed until it catches up to your target timestamp.
This is why long sessions with sparse snapshots feel laggy to scrub, and why some recorders insert periodic checkpoint snapshots: they trade upload size for seek speed. It’s the same trade video encoders make with keyframe intervals, and there’s no free lunch on either side.
Where the data volume actually comes from
People assume replay data is dominated by mouse movement. Usually it isn’t — mousemove events are tiny and heavily throttled. In practice the volume comes from:
| Source | Why it adds up |
|---|---|
| Full snapshots | Hundreds of KB each on DOM-heavy apps; multiplied by navigations |
| Large mutation bursts | Rendering big lists/tables serializes every new node |
| Inlined CSS | Modern apps ship megabytes of stylesheet text; it gets captured |
| Canvas/rich media capture | If enabled, sampled bitmaps dwarf everything else |
| Session length | A 40-minute session is simply 40 minutes of deltas |
A typical session on a typical SPA lands in the low single-digit megabytes uncompressed, a few hundred KB on the wire. If your sessions are much bigger than that, it’s almost always one of the rows above — usually snapshot frequency or a component that rebuilds a large subtree on every state change (the recorder faithfully serializes all of it, every time).
Knowing the pipeline also tells you what replay can’t show you: anything that never touched the DOM. For the boundaries — what gets recorded, what gets masked, what’s invisible by design — see what session replay actually records.
See the bug the way your user did
LogReplay captures session replays, console output, network requests, and errors in one timeline — so you stop guessing what happened before the ticket arrived.
Try LogReplay free