Frontend
How to catch the frozen tabs and jank your monitoring misses
Yadhunandan S Dev.to (EN Zone)
5 views
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
Why isn't the session you need in your monitoring tool?
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.
Why can't standard monitoring detect a frozen browser tab?
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.
What is per-session real user monitoring?
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.
The trade is deliberate. Percentiles are cheap and answer questions about the fleet; per-session logs are heavier and answer questions about a person. For a failure that belongs to one long session and ends in a frozen tab, the per-session log is the only artifact that can reconstruct it, so that is the one we keep.
Measuring what users feel with INP
The main number worth recording is Interaction to Next Paint. INP is the time from a user action to the next frame that visibly reflects it, a good measure of how responsive an interface feels. Thanks to the web-vitals library, it now reads across Chrome, Firefox, and Safari rather than one engine. The canonical definition lives on web.dev.
That reach is recent. INP is built on the browser's Event Timing API, and the property it depends on, interactionId, was Chromium-only from Chrome 96 back in 2021. Firefox did not ship it until version 144, and Safari until 26.2, both in late 2025. Safari had no Event Timing API at all before 26.2.
Because that cross-browser support is so new, a real share of your Firefox and Safari sessions, the ones still on older versions, emit no INP whatsoever. They report the recorder's other signals fine. They just cannot produce INP. So measure what fraction of your sessions can actually report INP before you quote a number.
The attribution build
A bare INP number tells you something was slow, not what. That is what the library's attribution build is for. It breaks each interaction into the pieces that actually consumed the time: the input delay before your handler ran, the processing duration of the handler itself, and the presentation delay while the browser laid out and painted the result.
That is the difference between "an interaction took 600 milliseconds" and "a click on this control spent 500 milliseconds in processing" (both figures illustrative). One is a fact. The other is a place to start reading code.
The three phases each point at a different cause:
A large input delay means the main thread was busy elsewhere when the click landed, so the fix lives elsewhere in the session, not in the handler.
A large processing duration puts the cost in your own handler.
A large presentation delay means the browser spent its time in layout and paint.
Same slow interaction, three different files to open, and for a long session input delay is the telling one: it often signals a tab that has been accumulating work.
Reading it is a few lines. You import onINP from the attribution entry point and log the breakdown it hands back.
import { onINP } from 'web-vitals/attribution';
onINP((metric) => {
const a = metric.attribution;
// Where the time actually went, plus the element the user touched.
console.log('INP', metric.value, 'ms on', a.interactionTarget);
console.log(' input delay ', a.inputDelay);
console.log(' processing ', a.processingDuration);
console.log(' presentation ', a.presentationDelay);
});
That is the teaching version, not production code, but it shows the shape of the signal: a value, a target, and three phases that say where the value came from.
What does INP not measure?
INP does not measure four real failures: dead and rage clicks where nothing repaints, scroll jank, sustained drags that stutter across many frames, and the merely-bad interaction dropped by worst-N sampling. INP is well defined, and these four fall outside its edges.
INP captures
INP does not capture
Clicks, taps, and key presses that produce a visible paint
Dead and rage clicks, where nothing repaints
How responsive those interactions feel
Scroll jank, which is continuous rather than discrete
Each interaction split into input delay, processing, and presentation
Sustained drags that stutter across many frames
The element the user touched
The merely-bad interaction dropped by worst-N sampling
Naming these four is not academic. Each is a gap you fill on purpose rather than a number that appears on its own. INP will never surface a dead click, so you watch the DOM for a non-response. It folds a stuttering drag into one clean entry, so you sample the gesture yourself. The list tells you where your monitoring is quiet.
Two of these four are worth separating honestly. Dead and rage clicks are already surfaced by mainstream tools through session replay. Building your own detector for them buys you ownership and full fidelity, not a capability you could not get otherwise. Scroll jank and sustained drag stutter are different. Those tools miss them too, because their frustration detection is click-based, so no off-the-shelf product is capturing them for you.
One INP interaction broken into input delay, processing, and presentation delay, beside the interaction types INP leaves out.
Small detectors for the four blind spots
Each blind spot gets its own small detector, cheap and specific to the failure it watches for, running alongside INP. The freeze from the opening needs a fifth detector, off the thread it watches, in its own section below.
Dead clicks and rage clicks
A dead click is the most literal version of "I clicked and nothing happened": the click lands, nothing repaints, and INP produces no entry at all. We catch it with a MutationObserver scoped to the subtree of the clicked element. Register the click, watch that subtree for a beat, and if nothing in the DOM changed, record a dead click.
// Dead click detector: did the click change anything in its own subtree?
const WATCH_MS = 300; // illustrative watch window, not a tuned constant
function watchForDeadClick(target, sessionId) {
let changed = false;
const observer = new MutationObserver(() => { changed = true; });
observer.observe(target, { subtree: true, childList: true, attributes: true });
setTimeout(() => {
observer.disconnect();
// document.contains guards a navigation away that removed the element.
if (!changed && document.contains(target)) {
recordDeadClick({ sessionId, at: Date.now() });
}
}, WATCH_MS);
}
document.addEventListener('click', (event) => {
watchForDeadClick(event.target, currentSessionId);
});
Three legitimate responses must not read as dead clicks:
A canvas repaint changes pixels without changing the DOM, so exclude those regions or watch them differently.
A navigation away removes the element, which the document.contains(target) guard handles.
A slow-but-real update can land after the watch window closes, so keep the window generous.
Several fast clicks in the same dead spot are a rage click, so count repeats in one place.
Scroll jank and sustained drags
Scrolling is continuous rather than a discrete interaction, so INP excludes it by design, and a heavy list can scroll badly while scoring nothing. A sustained drag is the same problem in reverse: one gesture spread across many frames. One that stutters the whole way across the screen can still leave a clean INP record behind it.
We measure both the same way, with bounded bursts of sampling scoped to a real gesture, taking timestamps only. Why timestamps only, and never a layout read in that loop, is a mistake we made first and describe in the next section.
The merely-bad click lost to worst-N sampling
The last blind spot is one we created ourselves. If the recorder keeps only a session's few worst interactions (worst-N sampling), it will usually miss the specific click a user is complaining about, which was merely bad rather than record-breaking. So we keep all of a session's interactions, not the worst N. The whole purpose of the recorder is to be walked back to one person, and you cannot do that with the exact interaction they noticed thrown away.
The measurement trap we walked into first
Our first attempt at measuring scroll smoothness was the obvious one, and it was wrong in a way worth remembering. We ran a requestAnimationFrame loop that read layout each frame to see how much the content had moved. Reading a layout property forces the browser to synchronously recompute layout. Doing that every frame injected work into the exact path we were trying to observe.
The monitor manufactured the jank it reported, worst in the heavy, already-slow parts of the app, precisely where an honest reading mattered most.
The fix was two changes:
We take timestamps only in the hot path and never read layout there.
We replaced the forever-running loop with bounded bursts scoped to a real gesture, measuring an actual scroll for a short window instead of paying a per-frame cost forever.
// Fixed scroll sampling: timestamps only, bounded to a real gesture. No layout reads here.
let frames = [];
let scrolling = false;
function sampleScroll() {
frames.push(performance.now()); // cheap: no layout property is read
if (scrolling) requestAnimationFrame(sampleScroll);
}
function onScrollStart() {
frames = [];
scrolling = true;
requestAnimationFrame(sampleScroll);
}
function onScrollEnd() {
scrolling = false;
// Turn frame timestamps into gaps off the hot path; long gaps are dropped frames.
const gaps = frames.slice(1).map((t, i) => t - frames[i]);
recordScrollJank({ sessionId: currentSessionId, gaps });
}
The durable lesson is short: a measurement that perturbs what it measures is close to worthless.
How do you detect a frozen browser tab from a thread that isn't frozen?
You detect a frozen browser tab by moving the detector off the main thread entirely, into a dedicated Web Worker that runs on its own event loop and keeps ticking while the main thread is wedged. Everything above measures degrees of slow. A freeze is different in kind: the main thread stops servicing work entirely and stays stopped. Any detector written the normal way runs on that same thread, so a setInterval meant to notice the freeze cannot fire during the freeze it exists to notice.
So we put the watchdog in a dedicated Web Worker, on its own thread. It keeps running when the main thread is stuck.
The heartbeat
The mechanism is a heartbeat. On a timer inside the worker, it posts a ping to the main thread and notes the time. The main thread has a tiny handler whose only job is to post an echo back. When the echo arrives, the worker compares the round trip against how long a healthy answer should take.
A late echo is the useful case. If the worker expected an answer within a frame or two and got one after, say, 1,800 milliseconds (illustrative, not a measurement from a real system), the main thread was blocked for roughly that long. The lateness of the echo is the measurement of blocked time. We get it this way on every browser, without the Chromium-only Long Tasks or Long Animation Frames APIs.
Here is the worker side: the heartbeat, the blocked-time reading, and the path that fires when the echo never comes.
// watchdog.worker.js runs on its own thread with its own event loop.
let identity = null; // session id + context, handed over while the tab is healthy
let pingSentAt = 0; // 0 means the last ping was answered
const PROMPT_MS = 16; // a healthy echo returns within a frame or two
const FREEZE_MS = 5000; // illustrative: no echo for ~5s means the tab is frozen
self.onmessage = (event) => {
const msg = event.data;
if (msg.type === 'identity') { identity = msg.snapshot; return; }
if (msg.type === 'echo') {
const roundTrip = Date.now() - pingSentAt;
if (roundTrip > PROMPT_MS) report('blocked_time', { ms: roundTrip });
pingSentAt = 0; // answered, healthy
}
};
setInterval(() => {
if (pingSentAt && Date.now() - pingSentAt > FREEZE_MS) {
report('freeze', { silentFor: Date.now() - pingSentAt });
pingSentAt = 0; // report once, then keep watching
return;
}
if (!pingSentAt) {
pingSentAt = Date.now();
self.postMessage({ type: 'ping' });
}
}, 1000);
function report(kind, data) {
if (identity) navigator.sendBeacon('/rum', JSON.stringify({ kind, ...identity, ...data }));
}
On the main thread the handler stays trivial, because if answering the ping is expensive it defeats the point. The main thread also hands the worker an identity snapshot while it is still healthy: the session id and the context needed to attribute an incident later.
// On the main thread: create the worker, hand it an identity snapshot, echo its pings.
const watchdog = new Worker('/watchdog.worker.js');
// While we are still healthy, give the worker what it needs to report on its own.
watchdog.postMessage({
type: 'identity',
snapshot: { sessionId: currentSessionId, path: location.pathname, startedAt: Date.now() },
});
// The only job of this handler is to answer immediately. Keep it trivial.
watchdog.onmessage = (event) => {
if (event.data.type === 'ping') {
watchdog.postMessage({ type: 'echo' });
}
};
When the echo never comes
The case that matters is when the echo never comes. If the worker hears nothing for several seconds (around 5 seconds is the illustrative threshold above), it concludes the tab is frozen. It does not wait for the main thread to recover, because it may never recover.
It sends the report itself, from its own thread, using the identity snapshot it was handed earlier. navigator.sendBeacon('/rum', ...) works inside a worker, so the freeze report goes out while the tab is still frozen, with no help from the thread that died.
This is the capability we could not get off the shelf, and the reason is structural. Mainstream error-monitoring and RUM tools run their code on the main thread, so a freeze that never ends is a report they can never send. Moving the watchdog off-thread is the whole difference between "the tab froze and we have nothing" and an incident report that arrives during the freeze.
The worker heartbeat, healthy on the left and failing on the right. Late replies measure blocked time; silence past a threshold triggers a freeze report the worker sends by itself.
Send the instant you know, do not batch
Send each slow interaction the moment it crosses the threshold, rather than buffering events and flushing on a timer, because of the order the bad things happen in. Buffering in memory and flushing periodically is the usual way to ship telemetry, and for most signals that is fine. For this recorder it is a liability.
The sequence is jank first, then freeze. The tab struggles, an interaction crosses into poor, and moments later the thread wedges. If those struggling-interaction events are sitting in an in-memory batch waiting for the next flush, the freeze takes the batch down with it. You lose the readings that describe the run-up to the very failure you are trying to explain.
So each slow interaction leaves the tab the moment it crosses the threshold, before the tab can lose it.
Run it on infrastructure you already own
You do not need a new vendor to build this. We already ran a self-hosted, open-source event analytics pipeline backed by a columnar database, the kind that is fast at counting and aggregating a single field across millions of rows. It was there for product analytics, good at swallowing a high volume of small, structured events and letting us slice them.
A performance signal is just another structured event. Every measurement the recorder takes is emitted as an event carrying its own session id, the surrounding context, and a pre-computed label describing how good or bad the number is. Because the classification already happened on the client, the dashboard needs no query engine and no statistics service, just a login and a bar chart. The adoption cost is close to zero: one more event type on a system that already exists, with nothing new to buy or install.
This is also what makes full fidelity affordable, which the opening scene needed. Because you own the event store, keeping 100 percent of sessions costs storage you already pay for, not a per-session premium. There is no sampling rate to tune and no on-error filter deciding which sessions survive. Every session is present.
Unlike a sampled commercial replay, the session behind any complaint, silent failure or not, is always there when you go looking. The customer writes in about yesterday, and this time the session is right where you expect it.
Cross-browser by default
One rule held the design together: the load-bearing signals have to work in every major browser. INP, blocked time from the worker heartbeat, action timing, and the DOM and memory gauges all do. The genuinely useful Chromium-only APIs, Long Animation Frames and precise memory measurement, are an enrichment layer, never the foundation. If your core diagnosis works in only one engine, you are blind to every user who complained from another.
Record for the machine, communicate for the human
Keep precise numbers for the engineer and pre-computed buckets for everyone else, both attached to the same event at record time. Internally the recorder keeps exact milliseconds. But when someone asks whether last quarter's work helped, milliseconds are the wrong language.
So every interaction is tagged at record time as good, needs-improvement, or poor, and the dashboard mostly counts those buckets. "Poor interactions fell from 14 percent of sessions to 3 percent" (illustrative, not a real system's data) is a sentence anyone can act on. "p75 improved by 300 milliseconds" (also illustrative) is not, even when it describes the same win. The buckets travel with the raw data rather than replacing it, so an engineer can still open one poor session and read it line by line.
What carries over
Three ideas outlast the browser specifics:
The first is the one the opening turns on: full fidelity beats sampling when you have to reproduce a specific complaint. A representative sample answers questions about the fleet, but the session one user is angry about is not a statistical question, and the only way to guarantee it is there is to keep all of them.
The second is the one the freeze build turns on: a detector that shares a thread with what it watches goes down with it, so the move that makes freezes observable at all is putting the watchdog somewhere the freeze cannot reach.
The third bit us hardest in practice: a measurement that perturbs what it measures buys you almost nothing, so keep the hot path to timestamps and push the expensive work onto another thread.
The rest is bookkeeping that matters: our users complain in sessions, so per-session logs beat transaction percentiles, and attaching the judgment to the fact at record time lets one event serve both the engineer reproducing a bug and the person deciding what to do next.
Read original: https://dev.to/yadhubuilds/how-to-catch-the-frozen-tabs-and-jank-your-monitoring-misses-3h2a
← Previous
Should Your Thread Keep the JVM Alive?
Next →
History of the Model Context Protocol (MCP)
Related
[Showoff Saturday] A new portfolio page!
Frontend
0
Reddit r/webdev
How I Built a Fast, Clean Wiki & Database for Steal an Egg
Frontend
2
Dev.to (EN Zone)
Got tired of writing READMEs, so I built a tool that does it for me
Frontend
3
Reddit r/webdev
I built a tool to check and repair broken Lottie files
Frontend
1
Reddit r/webdev
Comments0
No comments yet — be the first