How DOM Session Recording Works: Snapshots, Mutations & Replay
Session replay isn't video—it's DOM serialization. Here's how it works.
I watched a product manager debug a signup form for three hours last month. She was convinced the button was broken because users were clicking it and nothing happened. The button worked fine. The users were clicking a ghost — a tooltip that had rendered on top of the button due to a z-index bug that only manifested on viewport widths between 1280px and 1366px.
Session replay caught it in under a minute. Not because it recorded video. Because it recorded the DOM. Understanding how session replay works for SaaS onboarding flows changes how you debug user issues.
That distinction matters more than most people realize. Video would have shown "user clicked, nothing happened." DOM recording showed the exact element they clicked, its computed styles, its position in the stacking context, and the tooltip element that was eating the click event. You could hover over elements in the replay, inspect their CSS, see the z-index values. Try that with a screen recording.
Why video recording failed
The obvious way to record a session is screen capture. Grab pixels at 30fps and encode them as video. Some tools did this in the early 2010s. The problems were immediate:
Storage is brutal. A 720p video at 30fps runs about 5MB per minute compressed. A busy e-commerce site with 100,000 daily visitors, average session 8 minutes? 4TB of video. Per day. Hope you like paying AWS bills.
Playback is dumb. You can't search text. You can't inspect elements. You can't jump to "the moment they clicked the checkout button."
Privacy is impossible. Credit card numbers, passwords, PII — all baked into pixels. You can try to blur regions, but that requires knowing where sensitive content will appear before it appears. Our GDPR session replay PII masking guide covers this in depth.
By 2016, everyone serious about session replay had switched to DOM-based recording. FullStory, LogRocket, Hotjar, and later Datadog's session replay — all DOM-based. The open-source library rrweb formalized the patterns in 2018. JustAnalytics uses a similar architecture.
(I'll admit I didn't fully grasp this stuff until I had to debug a recording that showed the wrong CSS. Turns out our staging environment was serving a cached stylesheet. Spent two hours blaming the recorder before realizing I was the idiot.)
The initial snapshot
When recording starts — usually on page load — the recorder walks the entire DOM tree and serializes it to a JSON-like structure. Every element, every attribute, every text node:
{
"type": "Element",
"tagName": "div",
"attributes": { "id": "app", "class": "dark-mode" },
"childNodes": [...]
}
Each node gets a unique ID assigned during serialization. If we later say "mutation on node 47," the replayer knows exactly which element that is.
Stylesheets get special handling. External stylesheets are fetched and inlined. Computed styles generally aren't captured — the replayer reconstructs them from the CSS rules, just like the browser originally did.
This snapshot is the foundation. Everything that follows is a diff against this state.
Mutation observers
Here's where it gets elegant. (And honestly? This is the part that made me fall in love with the web platform a little bit.) The browser has a native API called MutationObserver that notifies you whenever the DOM changes:
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
recordMutation(mutation);
}
});
observer.observe(document.documentElement, {
childList: true,
attributes: true,
characterData: true,
subtree: true,
});
Every DOM change — element added, element removed, attribute changed, text modified — fires a callback. We serialize the change with a timestamp:
{
"type": "mutation",
"timestamp": 1689341847293,
"data": {
"type": "attributes",
"targetId": 47,
"attribute": "class",
"value": "btn btn-primary loading"
}
}
The recording is now just a stream of incremental events. Initial snapshot (maybe 200KB for a typical SaaS app), then mutations (typically 1-5KB per minute of active use). A 10-minute session might total 50-80KB compressed. Compare that to 50MB for video.
Non-DOM events
Users also move the mouse, scroll, resize the viewport, and type text. These aren't DOM mutations, so we capture them separately:
{
"type": "mousemove",
"timestamp": 1689341847500,
"data": { "x": 342, "y": 891 }
}
Mouse movements are batched aggressively — 10-15 samples per second is enough for smooth playback. Input events need care. For non-sensitive fields, we might capture the actual text. For password fields, we capture that something was typed but mask the value.
Playback
Reconstruction happens in a sandboxed iframe. The replayer deserializes the initial snapshot into a real DOM tree, injects it into the iframe, starts a timer, and applies mutations and events in timestamp order.
The iframe is fully isolated. No network access. Can't execute JavaScript (scripts are stripped during serialization). It's just a dumb DOM tree that gets manipulated by the replayer.
You can pause, rewind, fast-forward. Inspect elements using DevTools. Search for text. It's not video. It's a living document.
Privacy masking done right
Here's something that surprises people: proper privacy masking happens before transmission. Not on the server. Not during playback. In the browser, at capture time.
When we serialize an input element, we check its type:
function shouldMask(element) {
if (element.type === 'password') return true;
if (element.autocomplete?.includes('cc-')) return true;
if (element.dataset.jaMask !== undefined) return true;
return false;
}
If an element should be masked, we replace its value with asterisks. The actual keystrokes — P@ssw0rd123 — never leave the browser. What we record is ••••••••••••. Even if someone compromised the server, they couldn't extract passwords because we never had them.
JustAnalytics defaults to masking input[type=password], any input with autocomplete starting with cc-, and elements with data-ja-mask. You can extend this with CSS selectors:
JA.init({
siteId: 'your-site-id',
sessionReplay: {
maskSelectors: ['.pii-field', '[data-sensitive]'],
blockSelectors: ['.internal-only']
}
});
Blocked elements show as gray rectangles. They're not recorded, not serialized, not transmitted.
Edge cases
Canvas and WebGL. The <canvas> tag is in the DOM, but its contents are a bitmap — pixels drawn by JavaScript. The workaround: periodic canvas snapshotting with canvas.toDataURL(). This is expensive. A 1080p canvas at 2fps generates about 500KB/minute. Most tools offer a "canvas: 'ignore'" option.
Cross-origin iframes. Same-origin policy prevents us from reading their DOM. A Stripe payment form, an embedded YouTube video — all opaque. Most tools show a placeholder.
Shadow DOM. Web Components with closed Shadow DOM roots are like cross-origin iframes — we can't see inside. Open Shadow DOM is fine.
CSS-in-JS. Libraries like Styled Components inject styles at runtime. Good recorders handle this by batching mutations with microtask timing. If your replays show unstyled flashes that didn't happen live, this is probably why. (Ask me how many bug reports I've filed against replay tools before realizing the issue was Emotion's sheet ordering. The answer is "too many.") For teams migrating from Hotjar, check our migrate from Hotjar to JustAnalytics replay guide.
What this means for debugging
Session replay built on DOM recording isn't "like video but smaller." It's qualitatively different.
You can inspect elements. Hover over anything and see its DOM structure, computed styles, data attributes. We've written about correlating errors with funnel drop-offs — session replay is half of that equation.
You can search text. Looking for sessions where a specific error message appeared? Search for it.
You can correlate with errors. Because JustAnalytics unifies analytics, error tracking, and session replay under one session ID, you can go from "unhandled exception on line 342" to "here's exactly what the user was doing" in one click. We've covered the unified analytics approach in our Django middleware tutorial.
Privacy-first by default. Turn on session replay without a legal review for every field. Our Schrems III analysis covers the broader compliance implications.
The bandwidth reality
Teams hesitate because "it'll slow down the page." Fair concern. I've sat in those meetings. But let's check the math.
A typical 10-minute session: ~70-120KB total. That's nothing. A hero image is bigger. The JustAnalytics recorder script itself adds under 5KB gzipped.
For comparison:
- Google Analytics: ~45KB
- Sentry: ~30KB
- Intercom chat widget: ~200KB+
- Hotjar: ~60KB
Adding session replay isn't the straw that breaks the camel's back. If anything, it's annoying how many teams block session replay on "performance grounds" while happily loading 2MB of third-party marketing pixels. Pick your battles, people. For the full breakdown, see the case against the five-tool observability stack.
JustAnalytics bundles session replay with analytics, error tracking, APM, and uptime monitoring in one $49/month Pro plan ($39/month billed annually). An abandoned cart might be caused by an API timeout visible in APM, which triggered an error visible in error tracking, which the user experienced in session replay. Separate tools mean five logins, five billing cycles, and no shared session ID.
For teams running paid acquisition, click fraud detection from ClickzProtect can correlate with conversion drop-offs you catch in replay. And if you're debugging multi-account setups, JustBrowser lets you verify recording behavior across isolated browser profiles. If you're evaluating alternatives, our Highlight.io alternative comparison breaks down the feature differences.
The bottom line
DOM-based session recording sounds complicated until you understand the primitives. Take a snapshot. Watch for mutations. Serialize and compress. Replay by reconstructing. That's it. The implementation details get hairy — I won't pretend otherwise — but the concept is dead simple.
The efficiency gains over video are measured in orders of magnitude. The debugging capabilities are qualitatively superior. And done right — with client-side privacy masking — it's more secure than alternatives that capture everything and filter later.
That PM I mentioned at the top? She found the z-index bug because she could inspect the stacking context in the replay. Try that with a screen recording.
Frequently Asked Questions
How is DOM-based session recording different from video recording?
Video recording captures pixels at a fixed framerate — typically 30fps — and produces massive files (roughly 1-2MB per minute). DOM recording serializes the initial HTML structure, then only records changes (mutations) as they happen. A 10-minute session might produce 50KB of mutation data. Replay reconstructs the DOM in a sandboxed iframe, which is why you can inspect elements, search text, and zoom without quality loss.
Does session replay record passwords and credit card numbers?
Not if your tool is configured correctly. Privacy masking replaces sensitive inputs with asterisks or placeholder text before the data ever leaves the browser. JustAnalytics masks password fields, inputs with autocomplete='cc-number', and any element with data-ja-mask by default. You can extend this to entire sections using CSS selectors. The masking happens client-side — the actual keystrokes never hit our servers.
How much bandwidth does DOM session recording use?
A typical SaaS dashboard session (10 minutes, moderate interaction) produces 30-80KB of compressed recording data. Heavy DOM manipulation — like a spreadsheet app or real-time editor — can produce 200-400KB. Compare that to a 10-minute 720p video at around 50MB. The difference is roughly 100-1000x smaller.
Can session replay capture canvas elements and WebGL content?
Canvas is tricky. Standard DOM serialization can't capture canvas pixels because they're not in the DOM tree — they're rendered to a bitmap. Some tools (including JustAnalytics) offer canvas snapshotting that periodically captures the canvas as a PNG and embeds it in the replay. WebGL is even harder — you're essentially screenshotting GPU output. Most tools skip WebGL entirely or show a placeholder. If your app is canvas-heavy, ask your vendor specifically how they handle it.
Try JustAnalytics
All-in-one observability in one under-5KB script: cookieless analytics + error tracking + APM + session replay + uptime + structured logs. Replaces GA4 + Sentry + Datadog + Pingdom + LogRocket. Free tier (100K events/mo), Pro $49/month ($39 annual).
Author at JustAnalytics.