BugMojoBugMojoBugMojo
FeaturesPricingBlogGuidesAbout
Log inGet started
BugMojoBugMojo

Bug reports that actually help fix bugs — capture, replay, share.

Product

  • Features
  • Pricing
  • Get started
  • Log in

Resources

  • Blog
  • Guides
  • Compare
  • Glossary

Company

  • About
  • Contact
  • Privacy
  • Engineering
  • Playbooks
© 2026 BugMojo. All rights reserved.
AllGuidesEngineeringPlaybooksCompareGlossaryAlternativesBy roleBug tracking by framework
  1. Home
  2. Blog
  3. Engineering
  4. How rrweb Works: A Deep Dive into Browser Session Recording
Engineering

How rrweb Works: A Deep Dive into Browser Session Recording

A 2026 engineering deep dive into rrweb — how the open-source library captures DOM mutations, inputs, and scroll into a replay-able session timeline.

BugMojo TeamBugMojo Team·May 22, 2026·6 min read
A circuit board representing the low-level machinery of DOM serialization for browser session replay

Key takeaways

  • rrweb records DOM mutations and user inputs, not pixels — replays are deterministic and ~30× smaller than video.
  • The recording machine has two phases: one FullSnapshot event, then a stream of IncrementalSnapshot events.
  • A MutationObserver with subtree:true captures DOM changes; node identity is preserved via a serializer-assigned numeric id.
  • Performance is bounded: under 5% main-thread CPU on realistic UIs, payload typically 100–400 KB/min after gzip.

What rrweb actually records

rrweb captures five event categories: FullSnapshot (the serialized initial DOM tree), IncrementalSnapshot (mutations, inputs, scroll, mouse, selection, viewport, media events), Meta (the URL and viewport at recording start), Custom (your application's own events), and Plugin (extension data for canvas, fonts, and shadow DOM). Every event carries a millisecond timestamp from a shared performance.now() origin so the replay timer is monotonic.

A recording starts with a single FullSnapshot. The serializer walks the document, assigns each node a stable numeric id, and produces a JSON tree shaped like this:

type SerializedNode = {
  type: NodeType;          // Document | Element | Text | CDATA | Comment | DocumentType
  id: number;              // assigned by rrweb-snapshot at serialize time
  tagName?: string;        // for elements
  attributes?: Record<string, string>;
  childNodes?: SerializedNode[];
  textContent?: string;    // for text nodes
};

After the snapshot, every change in the DOM emits an IncrementalSnapshot with a source discriminator: 0 for mutation, 1 for mouse move, 2 for mouse interaction, 5 for input, 6 for touch move, 7 for media interaction, 10 for canvas, and so on. The replay engine dispatches each one to a handler that mutates a virtual DOM mirror; rrweb-player diffs that mirror onto a sandboxed iframe.

The full-snapshot serializer

The serializer is a recursive walker over document.documentElement. It assigns each node a unique numeric id (incrementing from 1), then captures the tag name, attributes (with style and src rewritten to absolute URLs), and recursively descends into childNodes. The id is the contract — every subsequent IncrementalSnapshot references nodes by id, never by NodeList position.

Three things make the serializer non-trivial. First, <style> rules are inlined as text so the replay doesn't refetch them. Second, <img> and CSS background URLs are rewritten to absolute URLs against the current document.location so the replay can resolve them from a different origin. Third, <canvas> and <video> content is captured by a dedicated plugin because they're stateful in ways the DOM tree can't express.

The serializer also skips nodes marked with the rr-ignore class, which is how you tell rrweb to omit a third-party widget or a sensitive region from recording.

Pro tip

The serializer takes a recordCrossOriginIframes flag. It defaults to false because cross-origin iframes cannot be inspected by same-origin JavaScript — the recording would silently capture an empty box. Leave it off unless every iframe you record is same-origin.

MutationObserver: capturing changes incrementally

After the full snapshot, rrweb attaches a MutationObserver configured with subtree: true, childList: true, attributes: true, characterData: true, and attributeOldValue: true. The callback batches mutations per microtask via a Set<Node> of touched nodes, then emits a single IncrementalSnapshot per microtask tick — not per individual mutation.

The batching is essential. Without it, typing 5 characters into a React input would emit 30+ mutation events (one per re-render of every child component). The microtask batching collapses those into one event per affected subtree per task.

// Simplified shape of the IncrementalSnapshot mutation event
type IncrementalMutationData = {
  source: 0;
  texts: Array<{ id: number; value: string }>;
  attributes: Array<{ id: number; attributes: Record<string, string | null> }>;
  removes: Array<{ parentId: number; id: number }>;
  adds: Array<{ parentId: number; nextId: number | null; node: SerializedNode }>;
};

Note the structure: adds includes a nextId (the sibling the new node sits before) so the replay can re-insert at the correct position even if the rest of the parent's children change. Removes carry just parentId + id — the replay deletes by id, doesn't care about position.

Capturing what the DOM doesn't observe

MutationObserver covers structural changes, but rrweb also captures input values, scroll positions, mouse coordinates, viewport resizes, selection ranges, and media-element seek events. Each lives in its own event source (1 through 10) and uses lightweight event listeners on document or window rather than per-element subscriptions.

Input handling is the most subtle. rrweb attaches a single input listener at the document level that captures the new value, the input's id, and the input type. If the input is in the mask list (passwords, sensitive selectors), the value is replaced with asterisks of the same length before serialization — so the replay still shows visible typing, just not the actual characters.

Mouse moves throttle to 50ms (configurable via mousemoveWait) to avoid emitting an event per pixel. Scroll events throttle similarly. The trade-off: replays are accurate to ~50ms of input timing, which is below human perception thresholds for cursor movement.

Replay: rebuilding the DOM from a stream of events

rrweb-player feeds the event stream through a Timer that emits events at their recorded timestamps. A virtual DOM mirror is mutated by each IncrementalSnapshot, then diffed onto a sandboxed <iframe> so the replay can have a completely different document state from the surrounding page without bleeding styles or globals.

The iframe sandbox is what makes rrweb's replays inspectable in a way video can't be. Inside the replay iframe, you have a real DOM. You can right-click, open the inspector, run document.querySelector(...), scroll the actual nodes — the replay is the page, just frozen at a specific timestamp. That capability comes for free from the architecture; there's no special "inspect" mode.

Info

The replay iframe is sandbox-isolated by default. To inspect computed styles, open the iframe context in your devtools (Firefox: right-click in iframe → Inspect Frame; Chrome: Cmd+Opt+I over the iframe area). The replay DOM is real, just nested.

Performance characteristics in production

On a typical React SPA (1–2 routes, ~500 DOM nodes per page), rrweb adds 2–5% main-thread CPU and produces 100–400 KB/minute of post-gzip event data. Heavier UIs (large virtualized tables, drag-and-drop boards) can hit 800–1500 KB/minute. Memory overhead is bounded because rrweb's event buffer is a rolling array, not a growing log.

The single largest performance lever is the slimDOMOptions config. It strips <meta>, <script>, <link> (when SSR-hydrated), and headless elements that don't affect rendering. For most marketing-app recordings, slimDOM cuts the FullSnapshot payload by 30–60%.

The second lever is inlineStylesheet. Inlining gives you a fully self-contained replay (works even if the original CSS host is down), but inflates the snapshot. For internal-only use where you control the CSS origin, set it to false and let the replay fetch styles at playback time.

Common mistakes

  • Recording cross-origin iframes. Will produce empty boxes. Either inline iframes via the iframe-recording plugin or accept the blind spot.
  • Skipping input masking. Even non-password fields can contain PII. Default to masking everything and selectively opt in to capture.
  • Ignoring slimDOM in marketing apps. The default config records every meta tag and link element. On a SEO-optimized page this can double the snapshot size.
  • Recording at 60fps when 10fps is fine. Lower mousemoveWait only if your debugging actually needs sub-50ms mouse precision; otherwise you're paying CPU for nothing.

Next steps

If you're building a tool that uses rrweb, the source at github.com/rrweb-io/rrweb is the authoritative reference — the packages/rrweb-snapshot/src/snapshot.ts file is where the serializer lives, and packages/rrweb/src/record/mutation.ts is the MutationObserver wiring. The privacy recipes doc covers masking strategies for production.

Want to see rrweb integrated into a real bug-tracking workflow? Install BugMojo — the Chrome extension captures rrweb sessions, console, and network in one click and ships them straight to your issue tracker.

Frequently asked questions

Sources

  1. rrweb GitHub repository — rrweb-io (2026)
  2. MutationObserver — MDN Web Docs — MDN (2026)
  3. rrweb privacy recipes — rrweb-io (2026)
Share:
BugMojo Team
BugMojo Team· Engineering & QA

The BugMojo team builds tools for developers, QA engineers, and PMs who want bug reports that actually help fix bugs.

On this page

  • What rrweb actually records
  • The full-snapshot serializer
  • MutationObserver: capturing changes incrementally
  • Capturing what the DOM doesn't observe
  • Replay: rebuilding the DOM from a stream of events
  • Performance characteristics in production
  • Common mistakes
  • Next steps

Get bug-tracking insights, weekly.

Engineering deep-dives, QA playbooks, and honest tool comparisons. No spam — unsubscribe in one click.

Keep reading

A laptop screen showing JavaScript code in a dark editor with Chrome DevTools open, illustrating browser extension development on Manifest V3
Engineering

Building a Bug Capture Browser Extension on Manifest V3

Engineering lessons from shipping a Chrome MV3 bug-capture extension: service worker death, rrweb buffering, MAIN-world hooks, and PII redaction at the edge.

May 22, 2026· 16 min
A partially obscured keyboard representing privacy and PII redaction in software
Engineering

PII Redaction in Session Replay: Patterns That Work in Production

How to redact PII from rrweb session replays, console logs, and network HAR data — GDPR and CCPA-compliant patterns we ship in production at BugMojo.

May 22, 2026· 7 min
A developer pair-programming with an AI coding assistant on a dark IDE, with a bug tracker visible on a second monitor.
Guides

How to Connect Claude Code to Your Bug Tracker via MCP

Step-by-step guide to wire Claude Code into BugMojo via the Model Context Protocol so your AI agent can read, triage, and update bugs in about 10 minutes.

May 22, 2026· 10 min
Browse:GuidesPlaybooks