Skip to content

A setInterval freeze watchdog measures backgrounded tabs and machine suspend, not freezes, unless you guard all three cases

TL;DR.

The standard JS event-loop watchdog (setInterval at 1s, report when drift exceeds a threshold) reports the browser's 60s background-tab timer clamp as a 59-second UI freeze, once per minute, per backgrounded tab. Suppressing on visibilitychange alone catches only the first tick. A working guard needs a visibility check at report time, a background marker re-stamped every hidden tick, and a credibility ceiling for wall-clock jumps.

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(); }   // BUG

Walk 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 transition

That 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

  • userCount lies on anonymous traffic. This issue reported userCount: 1 and read as one pathological client. With no logged-in user and PII off, every event has a null user.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_ms being 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.

No signals yet