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 granted | Figure |
|---|---|
| Passive CPU allowance | 25 ms per rolling 5 s |
| Slice handed to one walk | 4 ms |
| Attributable task that latches the circuit | 50 ms |
| Retained bytes, walk plus queued chunks | 2 MiB |
| A single replay chunk above this latches the circuit | 512 KiB |
| Leaving Constrained | 5 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.
| Ceiling | What it measures | Values it has held |
|---|---|---|
| Wire | UTF-8 bytes the chunk uploads | 128 -> 384 -> 512 -> 640 KiB |
| Heap | Bytes the in-progress tree is estimated to retain | 384 -> 960 -> 1,280 KiB |
| Nodes | Nodes admitted into the walk | 8,000 |
| Per stylesheet | Characters inlined from one sheet | 24 -> 128 -> 192 -> 256 KiB |
| All stylesheets | Characters inlined across one snapshot | 32 -> 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 constraint | What breaks when you get it wrong | The rule that settles it |
|---|---|---|
| A clock quantized to 1 ms, read per invocation | A hook costing 0.05 ms measures as 1 ms, and the budget looks spent | Threshold the rolling aggregate, never the sample |
| An allowance is a grant, not a cost | Counting the budget handed out as budget consumed constrains the work that was granted it | Charge overrun past a granted slice, never the allowance |
| A stylesheet that exists only by reference | An external <link> resolves to nothing inside the replayer’s sandboxed iframe | Re-emit the rules the host already parsed, inline |
| Inlined CSS counts against the heap ceiling | The estimate crosses its ceiling and the whole snapshot is discarded rather than degraded | Retry unstyled, then degrade the walk to a partial snapshot |
| A stylesheet cannot be drained in one task | Thousands of cssText reads inside one slice cross the 50 ms breach ceiling | Drain a sheet 24 rules at a time, across slices |
| A stylesheet is whole or it is nothing | A sheet clipped mid-rule keeps its layout and loses the custom-property tail that coloured it | Inline a sheet whole or keep its <link>; never clip one |
| Size has to be known before serialization | An overrun discovered afterwards leaves nothing to do but skip the checkpoint | Model serialized size during the walk itself |
| A fixed cadence cannot see a shorter route | A page that lives for less than the interval is never sampled at all | Arm a debounced checkpoint on every route change |
| Declining is an event, not a silence | A bare return makes a recorder that declined look identical to one that ran | A 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.
| Reason | When it is observed | What it means |
|---|---|---|
bundle_unavailable | Before the recorder loads | The host’s content security policy or network blocked our bundle |
sampling_not_selected | Admission | The project’s sample rate did not draw this session |
governor_constrained | Runtime | Recoverable host pressure; the recording pauses and resumes |
snapshot_ceiling_rejected | Capture | The walk abstained at a ceiling; the next checkpoint retries |
chain_incomplete | Teardown | Ingest 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.