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
- rrweb 2.0 is still alpha, and the canvas-webrtc plugin more so. Pin exact versions. A minor bump changed event shapes under us once.
- Cross-origin iframes need
recordCrossOriginIframesand cooperation across the frame boundary. Most of our "the stream is blank" incidents were this. - The canvas plugin needs
UNSAFE_replayCanvasturned on in the player, and the name is honest about how settled that path is. - The cost is DOM churn. A component re-rendering a large list on every tick will flood the event stream, and you learn that from the recording size, not from the page feeling slow.
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.