Isotherm

requestAnimationFrame does not fire in a hidden tab, and my code waited forever

An `await requestAnimationFrame` inside the snapshot settle pass hung forever in background tabs, holding the server's single-visitor election lock and blocking every other visitor.

The dashboard said "no snapshot yet". It said that for two days. No error in the console, no failed request in the network tab, nothing in the server logs, no exception anywhere. The page was being tracked — clicks were landing, the layout fingerprint was correct, the server was electing a visitor to send a snapshot. That visitor just never sent one. And because Isotherm elects exactly one visitor at a time (a thousand people on a page must not each serialize the DOM and upload a megabyte), the elected visitor holding the lock meant nobody else was ever asked.

The visitor was doing what I told it to. It was waiting for a frame.

The settle pass

A snapshot in Isotherm is a frozen standalone HTML copy of the page, taken once per layout fingerprint, so the heatmap can be drawn over the page as it was when the clicks happened rather than over a live iframe of today's page. Before cloning the DOM, capture() has to wake the page up. Modern pages do not finish rendering when they load; they finish rendering when you scroll them. Reveal libraries start elements at opacity: 0 and un-hide them from an IntersectionObserver. Images below the fold are loading="lazy" and have no pixels. Clone that document without scrolling and you get a tall empty void with one band of content where the viewport happened to be.

So settle() scrolls the live document through, step by step, waiting for a painted frame at each stop so the observers actually fire:

for (let y = 0; y < height && steps < MAX_STEPS; y += step, steps++) {
  window.scrollTo({ top: y, behavior: 'instant' as ScrollBehavior });
  await frame();
}

The original frame() was one line, and it was obvious, and it was wrong:

const frame = () => new Promise<void>((r) => requestAnimationFrame(() => r()));

requestAnimationFrame is not a timer. It is a callback into the browser's rendering steps, and a browser that is not rendering does not run those steps. A backgrounded tab is not rendering. Neither is an occluded window or a minimized one. (A visible window that has merely lost keyboard focus still paints, and still runs rAF — that is not the failure case, and I wasted an afternoon believing it was.) In the states that do stop rendering, the callback is not delayed. It is not queued with a longer interval. It is simply never invoked until the tab is shown again, which may be never.

So await frame() did not take 16 ms. It took the rest of the session. The promise never settled, so settle() never returned, so capture() never resolved, and the .then() in index.ts that would have uploaded the snapshot — and the .catch() that would have called reportFailure() — sat there attached to a promise that had no intention of doing either.

That is the shape of the bug that hurts. It produced nothing, reported nothing, and threw nothing. A pending promise is not an error. There is no timeout on await and no watchdog on the microtask queue. The one path in the whole SDK designed to make a failure visible, reportFailure(), is only reachable from a rejection, and a promise that never rejects never gets there. The server, seeing neither a snapshot nor a failure report, could not tell "still coming" from "dead", so it kept the lock held and the dashboard kept saying "no snapshot yet" in exactly the same words it would have used if the upload were three seconds away.

Who triggers it

Everybody, eventually. init() runs on page load. If a page is opened in a background tab — cmd-click, a link opened from Slack, a session restored on browser start, a page prerendered by the browser — the tab is hidden from the moment the script executes. Nothing about that is exotic. On an admin panel it is the norm: people middle-click three orders and read them one at a time.

There is a nastier version, and it is the one that survives the obvious fix. The capture is deferred: requestIdleCallback(run, { timeout: 10000 }) where it exists, and setTimeout(run, 1200) where it does not — Safari has no rIC, so a fifth of the traffic takes the plain-timer path. Either way there is a gap between electing the visitor and starting the work, and a second, much longer gap while settle() scrolls. A visitor can be visible when elected, hidden by the time run() fires, and hang mid-scroll. Or start settle() visible, get switched away at step 4 of 60, and stall there — with the page scrolled to y=1800 and the scroll restore, which happens after the loop, never executed. The user comes back to a page mysteriously scrolled somewhere they did not put it.

The fix, in two parts

First, no paint callback is allowed to be the only thing that can complete the job. Race it:

function frame(): Promise<void> {
  return new Promise((resolve) => {
    let done = false;
    const finish = (): void => {
      if (done) return;
      done = true;
      resolve();
    };

    requestAnimationFrame(finish);
    setTimeout(finish, 120);
  });
}

setTimeout is throttled in a background tab — clamped to roughly a second, sometimes far worse under intensive throttling — but it is serviced. It fires. rAF does not. 120 ms is the deadline in a visible tab, where a frame arrives in 16 ms and the timer is a backstop that should never win; anything above one frame is fine and 120 gives slow frames room without stalling the loop. The done flag matters: whichever fires first, the other still runs, and resolving twice is harmless but leaving a dangling handler that thinks it owns the promise is how you write the next bug.

Second, the loop is bounded. MAX_STEPS = 60. With a step of max(200, innerHeight * 0.9) that covers about 50 screens on a typical viewport. A page taller than that is either infinite-scrolling or broken, and either way a visitor's browser is not going to spend a minute scrolling it for my benefit. The image wait after the loop is bounded the same way — Promise.race against a 1500 ms timer — because a slow image costs a grey box in the backdrop, and a grey box is cheaper than a stalled tab.

In a tab that stays visible, that is worst-case arithmetic rather than vibes: 60 steps × 120 ms + 1500 ms is a ceiling of about 8.7 seconds on settle(), whatever the page does. In a tab that gets hidden mid-settle the ceiling is not 8.7 seconds, because the 120 ms timer is exactly the thing the browser starts clamping. Every remaining step then costs a throttled second or more, and settle() can run for a minute. That is still an enormous improvement over never, and it is still an ordinary bounded promise that resolves and lets the .catch() chain do its job — but "8.7 seconds regardless" is true of one case only, and I have written the wrong version of that sentence in a code comment before.

The part that actually mattered

Bounding the wait stops the deadlock. It does not make the snapshot good. A hidden tab does not paint, so the IntersectionObservers do not fire and the lazy images do not decode, no matter how long you wait. Capture there and you get exactly the empty pre-reveal page the settle pass exists to prevent — and it uploads cleanly, and the dashboard shows it, and it looks like a real snapshot of a real page that happens to be blank. Silent wrong output is worse than the hang. At least the hang left the lock recoverable.

So the capture is never started in a hidden tab. It waits to be looked at:

const whenVisible = (fn: () => void): void => {
  if (document.visibilityState === 'visible') {
    fn();
    return;
  }

  const onVisible = (): void => {
    if (document.visibilityState !== 'visible') return;
    document.removeEventListener('visibilitychange', onVisible);
    fn();
  };
  document.addEventListener('visibilitychange', onVisible);
};

If the tab is never brought to the front, no snapshot is taken, the election lock lapses, and the next visitor is asked instead. That is the right answer. One visitor out of a thousand declining to do the work costs nothing; one visitor out of a thousand uploading a blank page costs you the map.

Note that the idle deferral sits inside whenVisible, not around it. Idle callbacks are throttled in background tabs too, so scheduling idle work first and checking visibility second reintroduces the same class of problem one layer up.

Be precise about what this buys, though, because I was not. whenVisible reads document.visibilityState once, at scheduling time. Nothing re-checks it afterwards — not the idle callback, not the 1200 ms Safari fallback, not settle(), and nothing in snapshot.ts looks at visibilityState at all. The gate stops a capture from beginning in a hidden tab. It does not stop a capture that began in a visible tab from finishing in a hidden one, which is precisely the switch-away race above. When that happens, the frame timer carries the loop to the end and we upload a snapshot whose lower half was scrolled past while nothing was painting: some reveals fired, some did not. Half-settled, not blank, and it will look plausible.

Fixing that properly means re-reading visibilityState inside the loop and aborting — bailing out of settle() rather than shipping a document I do not trust. It is four lines and I have not written them, because the window is narrow and I would rather ship this note than a fix I have not been able to reproduce failing.

The road I did not take

The obvious cheaper fix is to drop rAF and use setTimeout(r, 16) in the scroll loop. It never hangs, it needs no visibility handling, and it is two lines shorter.

I rejected it because it is a lie about what we are waiting for. The reason settle() waits at each scroll stop is that IntersectionObserver callbacks are dispatched from the same rendering steps as rAF — that is the actual coupling, spelled out in the HTML spec's "update the rendering" algorithm. Waiting for a frame waits for the precise thing that has to happen. Waiting 16 ms waits for a number that usually exceeds the time a frame takes, on a machine that is not busy, which is not the machine we are running on: we just told the browser to scroll and it is now doing layout, decode and paint. A timer that is right 95% of the time on a fast page produces a snapshot that is subtly missing a reveal on a slow one, and nobody will ever notice, because it renders. That is the failure mode this whole codebase is written against.

Racing the frame against a timer keeps the correct primitive and puts a floor under it. In a visible tab it behaves exactly like await rAF. In a hidden one it behaves like a timer. That is the behaviour I want in both cases, and the general rule it comes from is the only thing here worth carrying to the next codebase: a rendering primitive can be starved indefinitely by conditions entirely outside your code, so never let one be the only thing that can finish a job.

Related