One user's page froze, and every dashboard stayed green. It was a long-lived single-page app, the kind of tab someone keeps open for hours. A customer writes in: yesterday the page locked up and they lost work. You open your monitoring to pull that exact session, and it is not there, because your replay tool sampled it away and it threw no error to catch on.
Modern error-monitoring and real user monitoring (RUM) tools are good: real Core Web Vitals in the field, session replay, per-session drill-down, and rage-click and dead-click detection all ship today. Three failures still fall through, though, and this post is how we caught them, with a per-session recorder built on tooling most teams already own.
TL;DR The session behind a complaint is often missing: replay tools sample sessions for cost, and silent failures throw no error to trigger capture. Keep every session on infrastructure you own, and any complaint stays traceable. Scroll jank and sustained drag stutter go uncaptured, because these tools detect frustration through clicks only. Each needs a small detector that samples the gesture with timestamps. A permanent main-thread freeze can't be reported from the main thread that froze. Run the watchdog in a Web Worker so it survives and sends the report itself, from a snapshot handed over while the tab was healthy. INP measures how responsive interactions feel, but misses dead and rage clicks, scroll jank, drags, and the merely-bad interaction. Per-session logs match how people actually complain.
On this page Why isn't the session you need in your monitoring tool? Why can't standard monitoring detect a frozen browser tab? What is per-session real user monitoring? Measuring what users feel with INP What does INP not measure? Small detectors for the four blind spots The measurement trap we walked into first How do you detect a frozen browser tab from a thread that isn't frozen? Send the instant you know, do not batch Run it on infrastructure you already own Record for the machine, communicate for the human What carries over
The session you need is often missing because session replay tools sample sessions to control cost. They reliably capture a session only when a JavaScript error fires. The failures this post is about throw no error. Unless that exact session landed in the small sampled fraction, it was never recorded.
Replay is expensive to store, so tools keep only a portion of traffic dependably: A sampled fraction of all sessions, often a small slice, say 10 percent (illustrative, not any vendor's exact default). Sessions where an error is thrown.
That works for loud failures. A thrown exception flags the session, and you can open it later and watch it back.
The failures here are silent. A tab that slows over a long session, a scroll that stutters, a drag that lags, a main thread that freezes: none of them throw. With no exception for on-error capture to catch, the session is recorded only by luck, if it fell inside the sampled fraction. When a user complains about yesterday, their session is unlikely to be among the sampled few.
Replay length is usually capped too, often to a set window, sometimes around an hour (illustrative). Sensible for cost, awkward for a long-lived single-page app. A session that runs for hours can be truncated. The stretch you care about, the slow run-up before the freeze, can sit outside the recorded window even when the session was sampled.
So session replay can miss the very sessions you care about most. Sampling optimizes for a representative view of the fleet, not for guaranteeing that one arbitrary complaint is reproducible. The fix is not a cleverer sampling rule. It is full fidelity: keep every session. That is affordable only when you record onto infrastructure you already own, which we come back to later.
Standard monitoring cannot detect a frozen browser tab for two reasons. It aggregates individual sessions into percentiles that cannot be traced back to one user. And the monitoring script it runs inside your page sits on the same main thread that just froze. Both problems point at the same missing unit of measurement, the session.
Error monitors and APM tools are built around transactions: a page load, a route change, an API call. Each is a bounded event with a start and an end, and the tool rolls thousands into percentiles you read on a dashboard. That model fits a server, where a request arrives, does its work, and leaves. A long-lived browser tab fits it far less comfortably.
The failure our users described was a property of a session, not a transaction. "It got slow after a while, then froze" is a claim about how one tab behaved across its whole lifetime. The state that explains it, memory creeping up, a growing document, an interaction that quietly regressed, lives in the gaps between the transactions the tools recorded.
Percentiles make this worse, because a percentile cannot be traced back to a session. When your p75 interaction latency (the value three-quarters of interactions come in under) rises, there is no thread to pull back to the person who complained. You cannot ask a percentile which session it came from, or what the tab looked like right before it stalled.
The aggregate is lossy by construction. It throws away exactly the per-session detail you need to reproduce a slow-then-frozen tab.
The second reason is sharper, and it motivates the whole build. A freeze that never ends cannot be reported by code that runs on the frozen thread, because that monitoring script wedges with it. The watchdog section returns to why, and what to do.
Per-session real user monitoring records the raw, ordered story of each browser session as its own log, tagged with a session id, instead of averaging interactions into percentiles. It matches the unit you record to the unit people complain in.
That is the single change of unit the rest of this post builds on. Keep the raw story of each session, and any complaint can be walked back to the events that produced it.
