Skip to content

rrweb

rrweb Explained: How DOM Session Recording Works

What rrweb is, how full and incremental snapshots turn a live page into a replayable event stream, why bundle size matters, and what a production recorder adds.

Published · Updated · 7 min read

rrweb — "record and replay the web" — is the open-source library underneath most web session replay tools, AnyReplay included. If you have ever wondered how a page can be recorded without recording video, rrweb is the answer, and understanding it explains the performance, privacy and security properties of every product built on it. This post walks through the recording model, the replay model, and what a production SDK has to add.

The core idea: serialise once, then diff

A browser page is a tree — the DOM — and the browser already tells you when it changes, through MutationObserver. rrweb turns that into a recording in two steps.

  1. Full snapshot. Walk the DOM and serialise every node — element, attribute, text — into JSON, assigning each node a numeric id. Inline the stylesheets so the replay renders correctly without the original CSS files.
  2. Incremental snapshots. Subscribe to mutations, mouse movement, clicks, scrolls, viewport resizes, input changes and a few others. Each becomes a small timestamped event that refers to nodes by id: "node 412 got a new child", "node 88's text changed", "the mouse is at (300, 240)".

The recording is the full snapshot followed by a stream of incremental snapshots. A page that does nothing produces nothing; a page that changes one label produces an event a few dozen bytes long. That is why DOM recording is so much smaller than video, and why an idle tab costs nothing to keep recording.

Event types

TypeWhen it firesPayload
MetaStart of recordingURL, viewport width and height
FullSnapshotStart, and after some navigationsThe serialised DOM tree
IncrementalSnapshotContinuouslyOne of: mutation, mouse move, mouse interaction, scroll, viewport resize, input, media, style change, and more
CustomWhen the application emits oneAnything: AnyReplay uses these for page navigation and rage-click markers

Mouse movement is sampled and batched, since a raw mousemove stream would swamp everything else. Inputs are recorded as the new value of a field — which is precisely where masking has to intervene, covered below.

Replay: rebuilding the page

The player does the reverse. It creates an iframe, rebuilds the full snapshot into a real DOM inside it (keeping the id-to-node map), then applies each incremental event at its timestamp relative to the start. A mutation event finds node 412 in the map and appends the described child; a scroll event sets the scroll offset; a mouse-move event moves a drawn cursor. Because it is a real DOM, the reviewer can pause, seek and resize, and rrweb can fast-forward by applying events without waiting.

Seeking backwards is the expensive operation: the player has to rebuild from the last full snapshot and re-apply events up to the target time. Tools that need cheap random access insert extra full snapshots periodically; the trade-off is payload size.

Scripts are never replayed

A serialised DOM includes <script> elements, and a naive rebuild would execute them inside the reviewer's browser — inside your dashboard, with your session cookies. rrweb handles this at both ends. At record time it replaces script bodies with SCRIPT_PLACEHOLDER, so the code never leaves the customer's page. At replay time it rebuilds scripts as <noscript>, so even markup that carried code cannot run.

AnyReplay adds a third layer: the replay iframe is sandboxed without allow-scripts. rrweb only requests script permission when its UNSAFE_replayCanvas option is on, so AnyReplay keeps that off and has a test that fails the build if anyone turns it on. In a real browser the iframe reports sandbox="allow-same-origin". The sandbox iframe entry covers why all three layers are needed.

Masking lives in serialisation

Because rrweb serialises inputs as values, the place to mask is the serialiser. rrweb exposes options for this — mask all inputs, mask elements matching a class, block elements matching a class — and AnyReplay exposes them as maskAllInputs, maskClass: 'ar-mask' and blockClass: 'ar-block'. maskAllInputs defaults to true, so recording field values is the customer's decision, made with maskAllInputs: false; what is masked regardless is the floor — passwords and one-time codes, payment fields, and any value that looks like a card number — which AnyReplay applies in code of its own, re-reading every event before the transport sees it. It has to: rrweb's masking hook is handed the value without the field, and a function that cannot see the field cannot tell a card number from a search box. A masked value is replaced with asterisks before the event exists; a blocked element is never serialised. How to mask sensitive data shows the configuration; the point here is architectural: masking at the serialiser is the only place that guarantees the value is not in the payload.

Why bundle size matters

rrweb is a substantial library, and the recording half runs on every visitor's device — on the phones and networks of the people least able to afford it. Every kilobyte is parse time before the page is interactive. A recorder that costs more than the page it records is a bad trade.

AnyReplay's browser SDK wraps rrweb's recorder (not the player, which only the dashboard needs) and comes to 34.8 KB gzipped. CI enforces a 45 KB budget: pnpm run size fails the build if the bundle grows past it, so a dependency upgrade cannot quietly double the cost. When you evaluate any replay tool, ask for the gzipped size of the script that ships to users, and ask whether anything enforces it.

What a production recorder adds

rrweb produces events. Getting them reliably from a browser to a server, safely, is the rest of the job. AnyReplay's SDK adds:

  • Chunking. Events are buffered and flushed every 200 events or 5 seconds, whichever comes first, as numbered chunks of at most about 256 KB, so a backlog after an outage leaves as several requests — see chunked upload. Ingest treats a repeated (session, sequence) pair as a retry and answers 202 with duplicate: true, so a flaky network cannot duplicate data.
  • Tail flush. When the tab closes, the remaining events are sent with a keepalive request. The Fetch standard caps in-flight keepalive bodies at 64 KiB, so the SDK keeps those payloads under 56 KB and falls back to a normal request for anything larger — a first chunk with a full snapshot is routinely several hundred kilobytes. See sendBeacon.
  • Retry with backoff. A failed chunk is kept and sent again after 2 seconds, doubling to a minute, never sooner than a Retry-After asks. A network that is merely away never ends the recording; only a real refusal, such as a paused project, does. See circuit breaker for why the SDK stopped counting failures in 0.2.0.
  • Deterministic sampling. sampleRate is decided once per visitor, so a recorded person stays recorded across pages.
  • Rage-click detection. Three clicks within a second inside a 30-pixel radius flag the chunk, so the dashboard can surface frustration without replaying everything.
  • Consent gating. requireConsent: true records nothing until the page grants it.

None of this is exotic. It is the difference between a demo that records your laptop and a recorder that survives a train tunnel.

What rrweb makes possible beyond replay

A recording that is structured text rather than pixels can be transformed. AnyReplay's session translation is an example: interface strings are extracted from the event stream — with the role they play, so a button reads like a button — translated, and substituted back into the stream before rrweb rebuilds the page. Because the substitution happens on the events rather than the rebuilt DOM, seeking and mutations stay translated with no work at replay time. Reviewing sessions in another language explains the pipeline.

The same property underlies the React Native recorder, which has no DOM to serialise and records the committed React element tree instead — a different serialiser feeding the same kind of structured, translatable stream.

Where to start

If you want to see an rrweb stream for yourself, install the AnyReplay snippet on a staging page with the Free plan (one project, 500 sessions a month; see pricing), record a session, and open the chunk payloads in your browser's network tab. The full snapshot is the big first request; everything after it is small. That shape is the whole idea.

Frequently asked questions

What is rrweb?

An open-source JavaScript library that records a web page as a full DOM snapshot followed by incremental change events, and replays that stream by rebuilding the DOM in an iframe.

Does rrweb record video?

No. It serialises the DOM and its mutations. The replay is a rebuilt page, which is why recordings are small and can be masked and translated.

Can a replayed page run JavaScript?

Not in AnyReplay. Script bodies are replaced with SCRIPT_PLACEHOLDER at record time, rebuilt as noscript on replay, and the iframe sandbox omits allow-scripts.

How big is the AnyReplay recorder?

34.8 KB gzipped, against a 45 KB budget that CI enforces on every build.

Start free — 1 project, 500 sessions a month

Install the snippet, record a session, and watch it back in your own language.