The standard in-page freeze detector is a heartbeat: setInterval at 1000ms, and on each tick compute drift = now - last_beat_at - 1000. A frozen renderer runs no timers, so the tick after recovery is the detector. It is the only instrument that sees a JS freeze from inside the page, and it is genuinely useful.
It also has three well-defined ways of lying, and shipping it without all three guards buries the real signal. Measured on a production SvelteKit site: 370 events in three days, of which ~89% were not freezes. A 100-event sample of the reported stall_ms:
| observed stall | n | what it actually was |
|---|---|---|
| 58-61s, modal 59005ms | 66 | the browser's background-tab setInterval clamp (~60s in Chromium) |
| >61s, up to 44,390,874ms (12.3h) | 18 | wall clock jumped: machine suspend, tab discard/restore |
| 3-46s | 16 | plausible; the actual signal |
A dead-flat 59,000ms across five browser engines and four operating systems is a timer clamp, not a freeze. And nothing recovers from a 12-hour JS block.
Guard 1: check visibility at report time
Obvious in hindsight, routinely omitted. A hidden tab's throttled tick is not a freeze:
const visible = document.visibilityState === 'visible';
if (!visible) return 'hidden';Guard 2: re-stamp the background marker on every hidden tick, not just at the transition
This is the subtle one, and it is what most implementations get wrong — including one written directly from a lesson that named the guard.
The usual shape stamps the marker in the event handler:
document.addEventListener('visibilitychange', () => {
if (document.visibilityState !== 'visible') last_background_at = Date.now();
});
// ... later, in the tick:
const stall_start = now - drift - BEAT_MS;
if (last_background_at < stall_start) { report(); } // BUGWalk it. Tab hides at T0; last_background_at = T0. First throttled tick at T0+60s: stall_start ≈ T0, so T0 < T0 is false and the tick is correctly suppressed. Second throttled tick at T0+120s: last_beat_at is now T0+60s, so stall_start = T0+60s, and T0 < T0+60s is true. It reports. So does every minute after that, until the per-session report cap runs out.
Worse, the usual REPORT_COOLDOWN_MS of 60,000 gates nothing here, because it is exactly equal to the clamp period.
Fix: keep the marker fresh for the whole hidden span.
last_beat_at = now;
if (!visible) last_background_at = now; // every tick, not just the transitionThat also covers the first visible tick after a restore, whose gap elapsed while hidden — a case the report-time visibility check cannot catch.
Guard 3: a credibility ceiling for wall-clock jumps
Machine suspend fires no visibilitychange if the tab was frontmost, so guards 1 and 2 both pass and the drift is unbounded. This is where the 12.3h event came from: a laptop closed overnight on a visible tab.
Date.now() and performance.now() do not disagree usefully here (platform-dependent behaviour across suspend), so a ceiling is the honest tool:
const MAX_CREDIBLE_STALL_MS = 120_000;
if (drift > MAX_CREDIBLE_STALL_MS) return 'clock-jump';Pick it above your largest real freeze. In this codebase the biggest genuine one was 46s (a WASM computation), and the watchdog's top bucket was 30s+, so 120s left plenty of headroom while removing the entire 69s-to-12h tail.
Put the policy in a pure function
All three guards are a decision over four numbers, so extract them and test without timers, fake clocks, or a DOM:
export function freezeSuppressionReason({ drift, visible, last_background_at, stall_start }: {
drift: number; visible: boolean; last_background_at: number; stall_start: number;
}): 'hidden' | 'backgrounded' | 'clock-jump' | null {
if (!visible) return 'hidden';
if (last_background_at >= stall_start) return 'backgrounded';
if (drift > MAX_CREDIBLE_STALL_MS) return 'clock-jump';
return null;
}The regression test that matters walks three consecutive throttled ticks, not one — a single-tick test passes against the buggy version.
Keep the counter when you add the filter
Suppressed stalls should still increment something (ph_capture('ui_freeze_suppressed', {stall_ms, reason, route}), a metric, a counter). Otherwise removing the noise also removes the only instrument that measures the rate, and a later reader cannot distinguish 'fixed' from 'filtered'.
Two triage notes that came out of the same investigation
userCountlies on anonymous traffic. This issue reporteduserCount: 1and read as one pathological client. With no logged-in user and PII off, every event has a nulluser.id, so Sentry collapses all visitors into one. The browser/OS spread over an event sample is the real answer — here 5 engines and 4 operating systems.- A suspiciously uniform value is the instrument talking. The modal
stall_msbeing 59005 across unrelated clients was the whole diagnosis. When one dimension of a sample is far too tight, suspect your own measurement before the users' machines.
Where the signal survives
After the guards, the events that remain are the ones worth having: on the same deployment, an Android WebView 90 device on a low-end phone kept reporting 3.9-9.5s stalls, which is exactly the renderer-blocking class the watchdog exists to catch. If your equivalent goes quiet after adding these guards, the ceiling is too low or the visibility check is firing on a case you did not intend.