Engineering · Reliability
Page Unresponsive: Seeing Page Freezes in Our Error Dashboard
How a forty-line client component put one of the most frustrating failures where we already look.
A user emailed us a screenshot: Chrome's "Page Unresponsive" dialog, with the two buttons that tell you the day is over, "Wait" and "Exit page." No console error. No network failure. No row in our error dashboard. From our side the session looked healthy until it ended, the worst possible shape for a bug. We had no idea how often it happened, on which pages, after which interactions, or whether one user hit it ten times an hour while everyone else was fine. We had a screenshot.
This post is about the freeze detector we shipped in May, a forty-line client component that notices when the page's main thread (the one thread that runs the page's JavaScript and paints it) was blocked, and reports it to the dashboard we read every morning. It rests on one trick. From inside the page, a freeze is invisible, but only while it lasts. Once the main thread comes back, the page can notice that more time has passed than it should have.
Nothing to throw
Our dashboard was built around thrown exceptions. A component crashes, a fetch fails, a render loop blows the stack: each one is a row. A freeze is none of those. The main thread is doing exactly what JavaScript told it to, so there is nothing to throw, and the dialog fires no event a page can hear:
A page notices it stopped
Every page has requestAnimationFrame, a browser function whose job is "call me back before the next paint", and on a healthy page it fires roughly sixty times a second. The detector scheduled itself with it, recorded the wall-clock time on each tick, and noticed when more than five seconds had passed since the last. If so, the main thread had been blocked, and the gap said exactly how long:
That is the entire freeze detector in one sentence. Everything else is making the report useful.
For attribution, a PerformanceObserver (the browser's feed of performance events) watched for long tasks, any single piece of work that holds the main thread for more than fifty milliseconds. A rolling buffer kept the latest twenty, so a report carried what the main thread was busy with right before it choked, with the script URL whenever the browser attached one.
One row per kind of freeze
A 7-second freeze and a 17-second freeze on the same page are almost always the same bug: the same button, the same expensive code, a different amount of data loaded that day. As two rows, they fill the dashboard with noise and make the bug look rare. So the detector normalized the page's URL in the report's message: /notes/7a30071b1c2d3e4f became /notes/*. The dashboard's fingerprinter already lower-cased each message and replaced digits with a placeholder, so freezes of different durations on the same route fell into one group too. Two illustrative freezes:
The boring failures
The freeze that was a lunch break. The first version reported a freeze every time someone came back to the tab. A hidden tab gets no animation frames, since nobody can see the paint, so when the user switches back, the next frame notices, correctly, that several seconds have passed. The main thread had been idle, not wrong. The fix was a flag on the visibilitychange event: when the page became visible again, the detector threw the next tick away as a known-bogus measurement:
Without it, the dashboard would have filled with phantom freezes every time anyone alt-tabbed. It is the kind of detail that reaches the design doc only in its second draft, because you have to run the thing once to find it.
Development looked frozen. Local runs hit hot-module reloads, debugger pauses and React's strict-mode double renders, all multi-second gaps to a heartbeat that does not know better. None are real freezes, so the detector armed itself only in production builds.
What one report carries
A freeze row had the shape of any other client error. One, illustrative, with the fields the dashboard kept:
Gap between two frames 17 s → a freeze
Page /notes/7a30071b1c2d3e4f
Route /notes/*
Report
message Page unresponsive: main thread blocked for 17s on /notes/*
level error (warn from 5 to 15 s, error from 15 s)
scope page freeze
context the exact duration, the original pathname, the route,
the recent long tasks with their script attribution
stack empty
The fifteen-second cutoff for an error is roughly where Chrome itself puts the dialog up. Below it, the page had frozen badly enough that we wanted to know, but the user probably saw no system prompt. Above it, we were in "Wait or Exit" territory, the bug class we cared about in the first place:
The stack was deliberately empty: there was no exception, only an observed gap. The long-task buffer was the closest thing to a stack we had, and more useful than a synthetic one.
What this taught us
requestAnimationFrame is the only API that knows both when the main thread is blocked and when it has come back. Anything built on setTimeout or setInterval misses the freeze or misreports it, because timers are at the mercy of the event loop they are trying to measure.
The visibility flag is not optional. A main-thread monitor that ignores tab backgrounding spams reports the first time a user comes back from lunch, and the second-week reaction to that is always to disable the monitor.
Grouping is what makes a new signal usable. The detector is small; the work that mattered was landing two freezes that are obviously one bug in one row, so triage reads "one freeze on the editor page, ten occurrences this week", not ten rows to merge in your head.
And a freeze the page never recovers from is still invisible. If Chrome kills the tab, the heartbeat dies with it. Catching those would take a watchdog in a Web Worker (a script on its own thread, apart from the page's). We have not built it, because we want to see how much of our freeze population is recoverable first, and would rather act on real numbers than a guess at what we cannot count.
What's next
The first month of data will tell us whether five seconds is the right floor. If we are drowning in 5-to-7-second reports from one slow render path, we will fix that path and possibly raise the threshold. If a long tail of freezes over 30 seconds shows up, that is the signal to build the watchdog and start catching the unrecoverable ones too.
Either way, the next time someone emails us a screenshot of the "Page Unresponsive" dialog, we will already have the row open.