My graduation project runs a small ONNX model in the browser, and my backend switch was one line: if (navigator.gpu) go WebGPU, otherwise WASM. It worked on my laptop and I stopped thinking about it. What made me go back was reading how a production image tool does the same job. Its check was the exact same line, but it was followed by a try/catch and a second session attempt that my code didn't have. I wanted to know what that second path was catching.
Why does requestAdapter() return null when navigator.gpu exists?
I tested on an Apple M4 / 16 GB with a Chromium 149 open-source build. With hardware acceleration on, navigator.gpu is defined and await navigator.gpu.requestAdapter() gives back an adapter whose info reads apple / metal-3. Then I relaunched with --disable-gpu, which is the same state you get by unticking "Use graphics acceleration when available" in settings. navigator.gpu was still there. requestAdapter() resolved to null. Nothing threw and nothing was logged at that step. So the object only tells you the browser ships the API. Whether there's a GPU behind it is a separate question, and requestAdapter() is the call that answers it. I've read that a blocklisted GPU, some VMs and remote desktop sessions can end up in the same state, but I only tested the settings toggle, so I can't tell you how common those are.
The screenshot is a small repro page running in that no-adapter Chromium. The three lines to read are the first check passing, the adapter coming back null, and the backend that ended up running, which is wasm. It's a single run, so the timing on it is only there to show the fallback happened.
How the production tool handles it
I used ImgIng (https://imging.ai/) for this part, calling its page's own TYBG.segment function directly (the same function its start button calls, not a click on the UI) on the same M4 Mac with Chromium 149 and a synthetic bottle image I generated. Its cutout code does if (navigator.gpu), tries to create an onnxruntime-web session with the webgpu execution provider, and if that throws it shows a progress message saying WebGPU isn't compatible and it's switching to WASM, then builds a wasm session. If both attempts fail, it keeps the first (WebGPU) error for the report, which I thought was a nice touch because that's usually the more useful one. Its device profile still reports webgpu: true in the no-adapter browser, because that function only looks at !!navigator.gpu. I didn't expect that, but the try/catch makes it harmless for the actual inference.
How much does the fallback cost?
Less than I feared. With the quick AI model (ISNet INT8) already cached, the time from starting session setup to the switch message was about 130 ms (20 ms to 151 ms), and the WASM session took roughly another 200 ms. A later rerun in the Chromium with hardware acceleration switched off measured 160 ms for the same step. The expensive part is what comes after. Running the model a second time on the same page took 1,951 ms on WASM and 409 ms on WebGPU. The mask came out the same both ways (identical histograms: 905,936 fully transparent, 22,934 partial, 119,706 opaque pixels), so a user on the fallback gets the same cutout, just slower.
In my own project I now call requestAdapter() and treat null as "no WebGPU", before any session is created. I kept a try/catch around the WebGPU session as well. I don't have a case where an adapter exists and session creation still fails, but I'd rather not depend on never meeting one. One thing I still haven't worked out is why the spec exposes the API with no adapter instead of just leaving navigator.gpu undefined; I assume it's so pages can tell "unsupported" apart from "unavailable right now", but that's a guess.
If your code has the same one-line check, turn off graphics acceleration in Chromium's settings, relaunch, and see which branch your page actually takes.
Top comments (0)