Session replay canvas freezes mid-session while the event timeline keeps advancing (rrweb recorder/replayer version skew + chunk-ordering)
Summary
Session replays freeze on the canvas partway through a session — the visual playback stops updating while the event/timeline list keeps advancing. It reproduces most reliably on pages that open a modal/overlay (lots of DOM mutations). We traced it to two independent, mutually-reinforcing root causes in the replay path:
- A. rrweb recorder/replayer major-version skew — the browser SDK records with
rrweb@1.1.3, but the app replays with rrweb@2.0.0-alpha.8.
- B. Chunk reassembly ordered by timestamp only (no chunk-index tiebreak) + silent event-drop in
DOMPlayer.tsx.
Root cause B is independently fixable and does not depend on the rrweb-version decision in A — you may want to split it into its own issue.
Environment / versions
| Component |
Package |
rrweb version |
| Recorder (browser SDK) |
@hyperdx/browser@0.24.0 → @hyperdx/otel-web-session-recorder@2.0.0 |
rrweb@1.1.3 |
| Replayer (app UI) |
@hyperdx/app (main, 2.29.0) → packages/app/package.json |
rrweb@2.0.0-alpha.8 |
- Storage/query backend: ClickHouse (self-hosted ClickStack), rrweb events routed via the
logs/out-rrweb collector pipeline.
- Session source
Timestamp column configured to the ingest timestamp column.
Symptom
- Replay canvas freezes at a stable point mid-session (for us, consistently right after a modal opens).
- The events/timeline panel continues to progress past the freeze point — because it is driven by a separate metadata query, not by the rrweb replayer's mirror state.
- No error is visible in the UI (see Root cause B — errors are swallowed unless
?debug=true).
Evidence
Using a metadata query over the recorded rrweb rows for one frozen session, we can see a node that is added and then referenced ~3s later, yet the replayer reports it missing at the reference time (the classic Node with id X not found divergence):
node <id> first added at 12:08:54.605 (in a ~40 KB, multi-chunk mutation event)
same node referenced at 12:08:57.5xx
=> replayer never has the node in its mirror; canvas freezes at ~12:08:57
The add event is present in storage, so the data is complete — the replayer simply never applied it (or applied a corrupted reassembly of it).
Root cause A — rrweb recorder/replayer version skew
The recorder emits events with rrweb@1.1.3 semantics, but the replayer is rrweb@2.0.0-alpha.8. Between 1.x and 2.x, rrweb replaced the INode/node.__sn model with a Mirror as the source of truth (rrweb-io/rrweb#868, a documented breaking change). Replaying 1.x-recorded incremental mutations on a 2.x-alpha Mirror can diverge and throw Node with id X not found on incremental snapshot events. Related upstream reports on adjacent versions:
The recorder has never moved off rrweb@1.1.3 (still 1.1.3 in the latest next build), and the app replayer has been 2.0.0-alpha.8 unchanged. Upstream rrweb has since shipped 2.0.0, 2.0.1, and 2.1.0.
Decision needed from maintainers: align the two sides — either (a) upgrade the recorder (packages/session-recorder) to rrweb 2.x and the replayer to a matching stable 2.x, or (b) pin the replayer back to a 1.1.3-compatible line so it matches already-recorded data. This affects whether existing recordings replay correctly, so it's a call only the maintainers should make.
Root cause B — chunk reassembly ordered by timestamp only + silent drop
Large rrweb events are split into chunks (rr-web.chunk / rr-web.total-chunks). The replay stream query selects these:
packages/app/src/sessions.ts:392-397 — selects rr-web.chunk AS ck, rr-web.total-chunks AS tcks.
packages/app/src/sessions.ts:411 — orderBy: \${source.timestampValueExpression} ASC`— **ordered by timestamp only, with nock` (chunk index) tiebreak.**
Chunks of a single event are written with the same timestamp, so ClickHouse may return them out of order. The player then concatenates and parses them:
packages/app/src/DOMPlayer.tsx:145 — currentRrwebEvent += body;
packages/app/src/DOMPlayer.tsx:147 — const parsedEvent = JSON.parse(currentRrwebEvent); (only when !chunk || chunk === totalChunks)
packages/app/src/DOMPlayer.tsx:150 — replayer.current.addEvent(parsedEvent);
packages/app/src/DOMPlayer.tsx:171-176 — catch (e) { if (debug) { console.error(e); } currentRrwebEvent = ''; }
Two failure modes fall out of this:
- Out-of-order chunks → corrupted JSON →
JSON.parse throws → the entire event is dropped. If the dropped event is a full snapshot or a key mutation (like the ~40 KB add above), the mirror is missing nodes and every later reference throws Node with id X not found.
addEvent throws are swallowed — the addEvent call is inside the same try, and in production (debug false) the error is silently discarded, the buffer reset, and playback continues with a permanently diverged mirror. That's why the canvas freezes while the timeline keeps moving, with no visible error.
Suggested fix for B (version-independent)
- Add a chunk-index tiebreak to the replay query ordering, e.g.
ORDER BY <timestamp> ASC, ck ASC (and/or explicitly sort by ck before concatenating in DOMPlayer.tsx).
- Stop silently dropping on
addEvent failure: surface the error outside debug, and attempt recovery (re-seek / rebuild from the last full snapshot) instead of continuing with a diverged mirror.
What we're asking for
- Confirmation of the intended rrweb version direction (Root cause A) so recorder and replayer are aligned.
- Consideration of the chunk-ordering tiebreak + non-silent error handling (Root cause B), which is safe to fix independently and would reduce freezes even before the version alignment lands.
Happy to provide additional metadata dumps or test against a specific build. Thanks!
Session replay canvas freezes mid-session while the event timeline keeps advancing (rrweb recorder/replayer version skew + chunk-ordering)
Summary
Session replays freeze on the canvas partway through a session — the visual playback stops updating while the event/timeline list keeps advancing. It reproduces most reliably on pages that open a modal/overlay (lots of DOM mutations). We traced it to two independent, mutually-reinforcing root causes in the replay path:
rrweb@1.1.3, but the app replays withrrweb@2.0.0-alpha.8.DOMPlayer.tsx.Root cause B is independently fixable and does not depend on the rrweb-version decision in A — you may want to split it into its own issue.
Environment / versions
@hyperdx/browser@0.24.0→@hyperdx/otel-web-session-recorder@2.0.0rrweb@1.1.3@hyperdx/app(main,2.29.0) →packages/app/package.jsonrrweb@2.0.0-alpha.8logs/out-rrwebcollector pipeline.Timestampcolumn configured to the ingest timestamp column.Symptom
?debug=true).Evidence
Using a metadata query over the recorded rrweb rows for one frozen session, we can see a node that is added and then referenced ~3s later, yet the replayer reports it missing at the reference time (the classic
Node with id X not founddivergence):The add event is present in storage, so the data is complete — the replayer simply never applied it (or applied a corrupted reassembly of it).
Root cause A — rrweb recorder/replayer version skew
The recorder emits events with
rrweb@1.1.3semantics, but the replayer isrrweb@2.0.0-alpha.8. Between 1.x and 2.x, rrweb replaced theINode/node.__snmodel with aMirroras the source of truth (rrweb-io/rrweb#868, a documented breaking change). Replaying 1.x-recorded incremental mutations on a 2.x-alphaMirrorcan diverge and throwNode with id X not foundon incremental snapshot events. Related upstream reports on adjacent versions:v2.0.0-alpha.7(one minor off from the replayer used here).rrweb@1.1.3.The recorder has never moved off
rrweb@1.1.3(still1.1.3in the latestnextbuild), and the app replayer has been2.0.0-alpha.8unchanged. Upstream rrweb has since shipped2.0.0,2.0.1, and2.1.0.Decision needed from maintainers: align the two sides — either (a) upgrade the recorder (
packages/session-recorder) to rrweb 2.x and the replayer to a matching stable 2.x, or (b) pin the replayer back to a1.1.3-compatible line so it matches already-recorded data. This affects whether existing recordings replay correctly, so it's a call only the maintainers should make.Root cause B — chunk reassembly ordered by timestamp only + silent drop
Large rrweb events are split into chunks (
rr-web.chunk/rr-web.total-chunks). The replay stream query selects these:packages/app/src/sessions.ts:392-397— selectsrr-web.chunk AS ck,rr-web.total-chunks AS tcks.packages/app/src/sessions.ts:411—orderBy: \${source.timestampValueExpression} ASC`— **ordered by timestamp only, with nock` (chunk index) tiebreak.**Chunks of a single event are written with the same timestamp, so ClickHouse may return them out of order. The player then concatenates and parses them:
packages/app/src/DOMPlayer.tsx:145—currentRrwebEvent += body;packages/app/src/DOMPlayer.tsx:147—const parsedEvent = JSON.parse(currentRrwebEvent);(only when!chunk || chunk === totalChunks)packages/app/src/DOMPlayer.tsx:150—replayer.current.addEvent(parsedEvent);packages/app/src/DOMPlayer.tsx:171-176—catch (e) { if (debug) { console.error(e); } currentRrwebEvent = ''; }Two failure modes fall out of this:
JSON.parsethrows → the entire event is dropped. If the dropped event is a full snapshot or a key mutation (like the ~40 KB add above), the mirror is missing nodes and every later reference throwsNode with id X not found.addEventthrows are swallowed — theaddEventcall is inside the sametry, and in production (debugfalse) the error is silently discarded, the buffer reset, and playback continues with a permanently diverged mirror. That's why the canvas freezes while the timeline keeps moving, with no visible error.Suggested fix for B (version-independent)
ORDER BY <timestamp> ASC, ck ASC(and/or explicitly sort byckbefore concatenating inDOMPlayer.tsx).addEventfailure: surface the error outsidedebug, and attempt recovery (re-seek / rebuild from the last full snapshot) instead of continuing with a diverged mirror.What we're asking for
Happy to provide additional metadata dumps or test against a specific build. Thanks!