Skip to content
Murali Krishnan A
All writing

07 Oct 2025

rrweb: a session is a string, not a video

The DOM is a tree of nodes, so a whole browsing session serializes to plain text. rrweb does that well enough to build on: I used it to stream physics simulations to classrooms, it is what PostHog session replay runs on, and it turns a bug report into a string you can re-render pixel for pixel anywhere.

7 min read · rrweb, dom, streaming, session-replay

A teacher was running one of our physics simulations on a video call, sharing it with a class on school wifi. I was on the receiving end, and the picture coming back to me was wrong in a good way. The text on the simulation was sharp. Sharper than a compressed video frame, sharper in a few places than the presenter's own tab.

It was not a video. The thing arriving over that connection was the DOM.

The DOM was always serializable

Everything on a web page is a node in a tree. An element, its attributes, its text, its place in the order. That tree is already a data structure, and a data structure can be written down.

rrweb is the library that writes it down properly. Call record() and it walks the document once into a JSON tree with a numeric id on every node. Then it installs a MutationObserver and a set of input and scroll listeners and starts emitting small events: node 412's text is now this, node 88 got a value of 45, the pointer moved here, the user scrolled that container. Every event is a plain object.

The hard parts are the ones you do not think about until they break. Shadow DOM. Same-page and cross-origin iframes. The value of an input, which lives in the DOM property and not the attribute. Scroll position. Web fonts. rrweb handles these, and that is most of the reason to use the library rather than write document.querySelectorAll("*") and hope.

What comes out is a list of objects you can JSON.stringify. A whole session, from first paint to the user closing the tab, is text.

What I was using it for

We build interactive physics and chemistry simulations, and teachers wanted to run one live for a class without everyone loading it on their own low-end machine. So, screen sharing, but for one app rather than a whole desktop.

rrweb made this almost boring. The presenter's page calls record() and hands each event to whatever transport you like. rrweb does not care what it is. We publish ours on an Ably channel because we already had one; a WebSocket would do the same job. The viewer runs rrweb-player in live mode, feeds it the events as they arrive, and gets back a live DOM in an iframe, patched mutation by mutation.

// presenter
rrweb.record({
  emit: (event) => channel.publish("record-event", pack(event)),
  recordCrossOriginIframes: true,
  slimDOMOptions: true,
});

// viewer
const player = new rrwebPlayer({ target: el, props: { events: [], liveMode: true } });
channel.subscribe("record-event", (m) => player.getReplayer().addEvent(unpack(m.data)));

The one place it does not reach is the canvas. Our simulations render with Babylon.js into a WebGL <canvas>, and to the DOM a canvas is one opaque box. rrweb serializes it and gets nothing useful. For that rectangle we fall back to pixels: the canvas-webrtc plugin calls captureStream() on it, opens a WebRTC connection to the viewer with simple-peer-light, and paints the frames onto the replayed canvas node on the far side. rrweb's node mirror is what keeps that video aligned with the DOM being rebuilt around it. The result is a hybrid, structure for the page and a real video track for the one part that has to be one.

We also prototyped pointing a multimodal AI at the same session, a voice model that could watch a simulation and talk a student through it. That one never got to use the DOM stream. The model takes frames and text, so the code flattens the video element to a JPEG a few times a second and sends a written description of every control next to it. When your consumer cannot hold a DOM, you are back to pixels and words. A browser can hold a DOM, so a browser gets the good version.

The performance difference

The reason to do any of this is the shape of the cost.

A slider drag from 20 to 45 is a handful of attribute mutations, a few hundred bytes. As video it is every frame of that motion, encoded at whatever resolution the viewer's screen happens to be. An idle page costs rrweb almost nothing, because nothing is mutating and there is nothing to emit; a video of the same still screen keeps paying its framerate. Text stays text, so there is no resolution tax, and a 4K monitor and a cheap laptop are the same number of bytes to send because each one paints its own pixels from the same description. Neither end runs a continuous encode or decode. The viewer is doing DOM patches.

It is not free everywhere. rrweb's cost tracks DOM churn, so a page mutating thousands of nodes a second can lose to a 720p clip of the same thing. Anything genuinely video-shaped, real video, a game canvas, a WebGL scene, wants real pixels and the plugin. But for ordinary application UI, forms, tables, dashboards, text, the mutation stream is smaller by an order of magnitude and sharper on the other end.

The same recording, played back later

Live streaming is one use. Persist the event log instead of forwarding it and you have session replay.

You keep the JSON, and later you load it into the same player, at whatever speed you want, with a scrubber. What you are watching is a reconstruction, not a recording. The DOM is really there in the page. You can open devtools on it, select the text the user actually saw, read the exact error message in the exact place it appeared, check what a form field contained. A video of a bug shows you the bug. A replay lets you inspect it.

This is what rrweb is used for most, and it is worth saying plainly: a bug report can be a string. Attach the session to the ticket, and whoever picks it up re-renders the user's screen, pixel for pixel, mid-failure, on their own machine.

How PostHog uses it

PostHog's session replay is rrweb. They record the event stream with posthog-js in the browser, batch it to their ingestion, and the replay UI is an rrweb player with console logs, network requests and product events laid over one timeline. They run their own fork to keep the parts they care about moving, but the core is the library.

posthog-js is the analytics on this site, which means the same recorder is one project setting away from running on the page you are reading. That is also why the masking configuration matters more than it would for video. You are shipping the DOM, not a blurred frame, so anything sensitive has to be masked at record time, before it is ever serialized. rrweb masks input values by default and takes class hooks like .rr-mask for the rest; PostHog wraps that in its own settings.

Strings instead of video assets

Add it up and the operational story is the real sell.

A session is a text blob. It gzips like text. There is no transcoding pipeline, no thumbnail generation, no media CDN, no separate renditions per resolution. You put it in Postgres or on S3 next to everything else and you are done. It plays back anywhere there is a DOM to rebuild it into, and it is exact, because it is reconstructed from structure rather than upscaled from a frame captured at some other size.

For a debugging workflow that is a different kind of artifact. Not "here is a fuzzy clip of what happened", but "here is the DOM, at that moment, that you can walk".

The parts that bit us

What I took from it

The DOM has always been data. rrweb is what made me use that fact instead of just knowing it.

Once a session is a string, the things you can do with it are the things you can do with any string. Stream it, store it, diff it, hand it to another machine and have the screen come back exactly. For anything whose consumer can hold a DOM, and a browser always can, that beats sending a picture and hoping the resolution was enough.