videoskilletdecisions Open the app ↗

0002 — Treat per-tab WebGPU sessions as a scarce budget

Status: superseded by 0004, 2026-08-07 (accepted the same day).

The measurements below are real and reproduce. The cause attributed to them here is wrong: it is not the count of devices a tab creates. A tab creates and holds four without dropping a frame. What ends a tab's rendering step is destroying a device that has been presenting — which every route measured here happens to do, once per iteration, which is why a count fitted the data. 0004 has the discriminating runs. Left unedited because the wrong turn is the more useful half: the budget model was falsifiable, the app acted on it, and acting on it is what turned up the real mechanism.

Context#

A tab is worth about two WebGPU sessions. The third GPUDevice created in one tab loads fine, reports no error, renders nothing, and requestAnimationFrame is never called for that tab again. Sometimes it is the second. The tab still reports visible, the browser stays responsive, and reloading lands in the same hole — only a new tab clears it.

Measured on Firefox Nightly 151 / Linux, scripts/rafceiling.mjs:

app     session 1:  72 rAF/1.5s  vis=visible
app     session 2:   0 rAF/1.5s  vis=visible   *** rAF STOPPED ***
app     session 3:   0 rAF/1.5s  vis=visible   *** rAF STOPPED ***

The control is what makes this a finding rather than a shrug. A static page whose entire content is a requestAnimationFrame counter, reloaded in the same tab of the same browser at the same cadence, took 21 reloads without dropping a frame. So this is not "reloading quickly is bad", not tab throttling, and not a harness losing its window. Only the tab that has held a GPUDevice a few times dies.

Two more properties, both of which kill an obvious workaround:

This is the freeze the app has been chasing: it is the frame 0 / STEP-DEAD / clock +0ms signature in the recorder, and it explains the recordings exactly. Of five real sessions in the trace ring, the two that ended in coldStall are the two that restarted the engine 15 and 16 times; the three that did not are the three that started it once.

A page cannot legally stop its own tab's rendering step, so this is a browser bug and there is no app-side cure. jbrowse-components has independently met the same shape in the WebGL2 path — "Firefox caps active WebGL contexts around 16 and Chrome around 8", tracked at module scope in packages/render-core/src/hal/webgl2Hal.ts — which is some comfort that per-tab graphics resources being capped, and the cap being worth counting, is not a local delusion.

Decision#

Treat the count as a budget the app spends and can therefore report on.

Consequences#

Reproducing it#

pnpm dev
node scripts/rafceiling.mjs --page=app        # dies at session 2 or 3
node scripts/rafceiling.mjs --page=control    # 21 reloads, never drops one

The script serves its own control page, so it needs nothing from this repo but a dev server to point the app arm at — written that way so it can be handed upstream as-is. Worth re-running against release Firefox and against Chrome: if the ceiling is Nightly-only, most of this matters much less to anyone but the dev box.