The hard part of session replay

Recording a page is the easy half. The constraints a recorder inside somebody else's page must satisfy, the two ceilings behind them, and what a replay gives.

Six glass channels run between ceiling plates, with one orange governor collar

The hard part of session replay is not the recording. A DOM walk and a pointer stream are a weekend. The hard part is everything a recorder must not do to the page it lives inside while it records, and every constraint below came from a moment where recording well and behaving well disagreed.

Recording well and behaving well are different jobs

A recorder on a test page can walk the whole document in one task and be right. The same code inside a customer’s checkout flow is a script nobody on that page asked for, competing for the main thread with the work a person is waiting on. So capture runs inside envelopes, and the figures worth knowing are the ones that stop it.

The scheduler hands cooperative work a passive allowance of 25 ms per rolling 5 seconds and slices it out 4 ms at a time, which is the 5 ms the host isolation contract permits with a millisecond held back for browser variance in DOM access. A single attributable task at or over 50 ms counts as a breach rather than as pressure, and it latches the replay circuit for the rest of the runtime epoch.

What replay is grantedFigure
Passive CPU allowance25 ms per rolling 5 s
Slice handed to one walk4 ms
Attributable task that latches the circuit50 ms
Retained bytes, walk plus queued chunks2 MiB
A single replay chunk above this latches the circuit512 KiB
Leaving Constrained5 s below 80 percent of every envelope

Two ceilings, and why merging them is the bug

The snapshot carries two byte ceilings, and for a long time they were one constant. That is the most expensive of the constraints below, because it looked like a tuning problem and was a units problem.

The heap estimator models a JavaScript heap: 64 bytes per element plus two bytes per character, because strings are UTF-16 and every node is an object. The wire figure counts UTF-8 bytes with no per-node overhead. Charging one against the other billed 32 KiB of inline CSS, ordinary for a utility-class framework, at 65,536 bytes. A production page that fits on one screen was cut off at 509 nodes, 6 percent of the node ceiling, and reported as a memory truncation.

CeilingWhat it measuresValues it has held
WireUTF-8 bytes the chunk uploads128 -> 384 -> 512 -> 640 KiB
HeapBytes the in-progress tree is estimated to retain384 -> 960 -> 1,280 KiB
NodesNodes admitted into the walk8,000
Per stylesheetCharacters inlined from one sheet24 -> 128 -> 192 -> 256 KiB
All stylesheetsCharacters inlined across one snapshot32 -> 128 -> 320 -> 384 KiB

Every raise in that table was bought by a production page. The last was an app route that serialized to 570,281 bytes, because its framework injects around 280 KiB of CSS as native <style> elements, which no unstyled retry can shed. The checkpoint was rejected twice and the replay stayed frozen on the previous page.

The ratio between them has to stay deliberate. A page allowed to put 640 KiB of mostly-ASCII markup on the wire is estimated to retain roughly twice that in the heap, so 1,280 KiB keeps the heap ceiling from firing first on a document that would have fit the upload budget. Fusing them again would restore the original defect: half an envelope spent before a single element is walked. The wire figure is also the only one a customer pays for, and chunks are gzipped at roughly 10:1 for CSS and 5:1 for markup, so a full 640 KiB chunk lands around 60 to 90 KiB.

Nine constraints, each found the expensive way

Every row is closed. Each one came out of our own production sessions and is anchored in the decision record or the commit that settled it, the cause is named, and the rule in the last column is what the recorder does today. None of these describes current behaviour.

The constraintWhat breaks when you get it wrongThe rule that settles it
A clock quantized to 1 ms, read per invocationA hook costing 0.05 ms measures as 1 ms, and the budget looks spentThreshold the rolling aggregate, never the sample
An allowance is a grant, not a costCounting the budget handed out as budget consumed constrains the work that was granted itCharge overrun past a granted slice, never the allowance
A stylesheet that exists only by referenceAn external <link> resolves to nothing inside the replayer’s sandboxed iframeRe-emit the rules the host already parsed, inline
Inlined CSS counts against the heap ceilingThe estimate crosses its ceiling and the whole snapshot is discarded rather than degradedRetry unstyled, then degrade the walk to a partial snapshot
A stylesheet cannot be drained in one taskThousands of cssText reads inside one slice cross the 50 ms breach ceilingDrain a sheet 24 rules at a time, across slices
A stylesheet is whole or it is nothingA sheet clipped mid-rule keeps its layout and loses the custom-property tail that coloured itInline a sheet whole or keep its <link>; never clip one
Size has to be known before serializationAn overrun discovered afterwards leaves nothing to do but skip the checkpointModel serialized size during the walk itself
A fixed cadence cannot see a shorter routeA page that lives for less than the interval is never sampled at allArm a debounced checkpoint on every route change
Declining is an event, not a silenceA bare return makes a recorder that declined look identical to one that ranA closed catalogue, written into the session’s evidence

The second row makes the point best. The governor grants cooperative work an allowance and the scheduler spends it, and that same rolling total was also read as utilization of the CPU envelope. A checkpoint is a DOM walk that runs precisely until its allowance is gone, so it hit the constraining threshold inside the first second of every checkpoint, had the capability withdrawn from under it, stopped, waited out recovery and did it again. Fourteen days of production evidence showed 78 of 79 replay declines as governor_constrained, on Chromium and WebKit alike. The recorder was switching itself off and filing it as host pressure.

Firefox is the row we would have argued against in review. WebKit and Firefox truncate performance.now() to a 1 ms quantum as a timing-attack mitigation, so a per-invocation threshold below that quantum measures the clock rather than the cost. Across eighteen five-minute journeys the probe fired on every attempt. The contract being enforced was never violated. The instrument was, and the threshold moved to a rolling aggregate the same week.

A failure nobody can see is the expensive one

Session replay declines itself in nine places, five before the recorder bundle is even fetched and four inside it, and every one of them used to be a bare return. From outside, a recorder that declined and a recorder that ran perfectly produced identical sessions: no error, no warning, no recording.

That is worse than a crash, because a crash gets investigated. The fix was a closed catalogue of 29 availability reasons, each written into the evidence queue the session already carries, so “why is there no replay here” becomes a row somebody can read.

ReasonWhen it is observedWhat it means
bundle_unavailableBefore the recorder loadsThe host’s content security policy or network blocked our bundle
sampling_not_selectedAdmissionThe project’s sample rate did not draw this session
governor_constrainedRuntimeRecoverable host pressure; the recording pauses and resumes
snapshot_ceiling_rejectedCaptureThe walk abstained at a ceiling; the next checkpoint retries
chain_incompleteTeardownIngest refused the segment’s own terminal report

The last row took longest to notice. A recorder posts the segment’s end from stop(), and that report is the only thing that writes the row a replay is reconstructed from. We discarded its answer, so a body the schema refused came back as a rejection nothing read: the session closed looking like a clean recording, and the gap the panel exists to draw had no row to draw it from.

The catalogue also has to keep recoverable and terminal apart. A ceiling rejection, a checkpoint that ran out of wall-clock time and recoverable governor pressure are all reportable, and none of them ends a recording. Marking a segment truncated by one would leave it truncated for the rest of the session’s life.

What the recording will not give you

Three of these are limits rather than defects, which means the useful thing is knowing them before a session rather than after one.

A cross-origin stylesheet served without CORS headers throws when its rules are read. That is a browser security boundary rather than a choice of ours, so those sheets keep their <link> and are fetched at playback time by the panel’s asset proxy. A page styled entirely from a locked-down CDN depends on that fetch, and serving the sheet with an Access-Control-Allow-Origin header removes the dependency entirely.

Typed characters are never recorded. Inputs are masked at capture, always, with no setting to turn it off, so a replay shows that a field was filled and never what went into it. That is the correct trade for a recorder running on a page where somebody may be entering a card number, and it means a bug that depends on the exact input still needs the reporter to say what they typed.

And there are two capture paths. Full-fidelity recording goes through rrweb where the engine supports it, and the cooperative sampler carrying the ceilings above is the fallback. The governor, the availability catalogue and the ownership rules cover both, which is what makes the fallback safe to fall back to.

Common questions

Why is session replay hard to build?

A recorder runs inside somebody else's page, so every capture decision is also a performance decision. Walking the DOM, reading a stylesheet and buffering pointer events all cost the host page main-thread time and memory it did not agree to spend, and the recorder has no way to know what else that page is doing.

What are the size limits on a session replay snapshot?

Our cooperative snapshot carries three ceilings: 640 KiB of serialized bytes on the wire, 1,280 KiB of estimated retained heap while the tree is being built, and 8,000 nodes. The wire and heap ceilings measure different magnitudes against different budgets and are deliberately not one number.

Why do session replays sometimes play back without any styling?

A replayer renders inside a sandboxed iframe on another origin, where the captured page's external stylesheet links resolve to nothing. Unless the recorder reads the parsed rules out of the host's own CSSOM and re-emits them inline, the replay paints an unstyled skeleton of the right DOM.

What happens to a session replay when the page it is recording gets busy?

Our governor withdraws the replay capability at 80 percent of any envelope and the recorder closes its segment as a named pause rather than a failure. Recovery takes 5 seconds below that figure, or 30 seconds below half of every envelope after a harder breach.

How do you know whether a session was recorded?

Every session carries the reason its recording is absent, drawn from a closed catalogue of 29 causes: sampling, consent, host CPU pressure, a content security policy that blocked the bundle. A recorder that fails silently reports the same thing as one that worked, so absence is written down rather than inferred.

The Notarus team

The people who build Notarus. We write up the decisions behind the product when the reasoning is worth more than the changelog line.

Keep reading

Reports with the evidence attached.

Start free, one script tag.

<script src="…/widget.js" data-key="pb_pub_…">