The WebGPU Shader Bug That Broke Best Quality — A Debugging Postmortem
Best Quality background removal worked in some browsers and silently failed in others with a cryptic WGSL compile error. Here is the trail we followed to the root cause — a bug deep in a machine-learning runtime — and the one-line-idea fix.
Every so often a bug is satisfying precisely because the error message is so hostile. This is the story of one that broke our highest-quality background-removal mode on a chunk of devices, produced a wall of WebGPU validation noise instead of a stack trace, and turned out to be a defect several layers below our own code. If you build anything on in-browser machine learning, the shape of this hunt may save you an afternoon.
If you just want the conceptual background on how Best Quality picks a runtime, we wrote that separately: How Best Quality Uses WebGPU with a WASM Safety Net. This post is the incident report.
The symptom
Fast mode worked everywhere. Best Quality — the mode that uses a heavier, sharper model for hair, fur, and fine edges — worked on some machines and, on others, returned "couldn't find a foreground subject" for an image that Fast had just cut out perfectly. Same image, same browser session, opposite result.
The console was not subtle. On the failing devices it filled with hundreds of lines like:
An uncaught WebGPU validation error was raised: Error while parsing WGSL:
error: no matching constructor for 'i32(vec4<u32>)'
index += i32(uniforms.input_dims[input_dim_idx]);
- While calling [Device].CreateShaderModule(...).
Followed by cascade after cascade of "invalid ComputePipeline," "invalid CommandBuffer," "invalid due to a previous error." When a GPU shader fails to compile, everything downstream that depended on it also fails, so a single root error metastasizes into a screenful.
Reading the actual error
Strip away the noise and one line matters:
no matching constructor for 'i32(vec4<u32>)'
WGSL is the shading language WebGPU uses. This is a type error in generated shader code: something tried to construct a 32-bit signed integer (i32) out of a four-component vector of unsigned integers (vec4<u32>). There is no such constructor — you cannot turn four numbers into one that way — so the shader refuses to compile.
The important word is generated. We did not write that shader. Neither did the model. It was emitted at runtime by the machine-learning library that executes the model's math on the GPU. The offending expression indexed a uniforms.input_dims array and cast the result to i32. That is the fingerprint of a specific operation in the model's compute graph being lowered to a GPU program — and the lowering was buggy for that operation's shape.
Following it down a layer
Our Best Quality model is a segmentation network run through a popular in-browser ML runtime. That runtime has, historically, two ways to run on the GPU: an older path where GPU programs are generated in JavaScript/TypeScript and handed to WebGPU as WGSL text, and a newer path where a native (C++) engine compiled into WebAssembly talks to WebGPU directly.
The version we shipped was pinned — indirectly, through a dependency — to a build from early 2025 that used the older, text-generation path. And in that build, the code that generates the shader for one particular tensor operation had exactly the bug the error described: for tensors above four dimensions it packed a shape array as vec4<u32> but then indexed and cast it as if it were a flat list of integers. Our model's graph hits that operation with a five-dimensional tensor. On a device where the GPU path was chosen, the broken shader was generated, compilation failed, the compute pipeline was invalid, and the model produced garbage — which our pipeline correctly read as "no subject here."
Why only some devices? Because the broken path only runs when the GPU path is selected. On machines that fell back to the CPU/WASM path — older browsers, certain drivers — the buggy shader was never generated, so Best Quality quietly worked. Same code, different runtime branch, opposite outcome. That is why it looked non-deterministic from the outside.
The fix is a one-sentence idea
You might expect the fix to be "upgrade the runtime to a version where the shader generator is patched." We checked. The buggy generator is still present in the latest releases of that text-generation path. Upgrading the version number alone would not have helped.
But there was a better door. From a later runtime version onward, the GPU path was rewritten: instead of generating WGSL text in the browser, the newer builds run the GPU work through the native C++ engine compiled into WebAssembly. The broken text-generation code is still in the package — it simply never runs, because that whole strategy was replaced. The buggy shader generator became a dead code path.
So the fix was to point our runtime at the newer, native GPU build and pin the version that ships it. One redirect. The broken WGSL is never emitted because nothing in the new path emits WGSL at all. Best Quality on WebGPU went from "fails on a shape-five tensor" to "runs the operation in compiled native code that never had the bug."
Making failure impossible to hide
A root-cause fix is necessary but not sufficient. The original bug was nasty specifically because it failed silently — no exception, just an empty result that looked like "no subject." So alongside the runtime change we added a layer that refuses to trust a suspiciously empty answer.
Now, when the GPU path returns a mask that is essentially blank, we do not accept it. We re-run the same image once on the CPU path and compare. If the CPU also says the subject is tiny or absent, we believe it — some photos genuinely have almost no foreground. But if the CPU recovers a real subject that the GPU missed, we take the CPU's answer and move on. A broken backend can no longer quietly hand you an empty cutout; the worst it can do is make you wait a few seconds for the safety re-run.
We also stopped a related failure where a broken run inside the editor would clear your canvas back to a blank state. A failed removal now shows an in-place error card with a retry button, instead of silently dumping you onto an empty canvas.
What to take from it
Three things generalize beyond this specific bug:
- When a generated-code error names an expression you did not write, go down a layer. The bug is in whatever generated it, and the fix usually lives in that dependency's design, not yours.
- A newer version number is not the same as a fix. We had to find the version where the strategy changed, not just where the patch count went up. Read release notes for rewrites, not just bug fixes.
- Silent wrongness is worse than a crash. The most valuable part of this work was not the root-cause fix; it was making the failure mode loud, so the next time a backend misbehaves, it cannot pretend to have succeeded.
If Best Quality had been giving you trouble, it is fixed — try it again on the image that used to fail. And watch the console stay quiet.