Using Error Scopes to Localize GPU Validation Failures
A WebGPU validation error does not throw. It surfaces asynchronously, through an error scope you pushed earlier or through an uncapturederror event if you did not, and by the time it arrives the call that caused it has long returned. That makes scopes the primary localisation tool: they answer “which of these forty resource creations was invalid” without a bisect. Using them well means understanding three properties that trip people up — they nest as a stack, they resolve as promises, and a submission is validated when it is submitted rather than when it is recorded. This page covers all three, plus where to place scopes so they cost nothing measurable. It is one stage of debugging and validation for spatial GPU pipelines.
Runnable reference implementation
The useful granularity is a phase rather than a call. Wrapping every createBuffer in its own scope is measurably slow; wrapping the whole allocation phase costs nothing and narrows the search to a handful of lines.
/** Run a phase inside a scope and attribute anything it raises. */
async function scoped<T>(
device: GPUDevice,
label: string,
fn: () => T,
): Promise<T> {
device.pushErrorScope("validation");
device.pushErrorScope("out-of-memory");
const result = fn();
// Pop in reverse order — the scopes are a stack.
const oom = await device.popErrorScope();
const validation = await device.popErrorScope();
if (oom) throw new Error(`[${label}] out of memory: ${oom.message}`);
if (validation) throw new Error(`[${label}] invalid: ${validation.message}`);
return result;
}
// Usage: one scope per phase, not per call.
const pipelines = await scoped(device, "build-pipelines", () => buildAllPipelines(device));
const buffers = await scoped(device, "allocate-tiles", () => allocateTileBuffers(device));
Per-frame work needs the scope to span the submission, not the recording, because that is when the command buffer is validated.
function submitFrame(device: GPUDevice, encoder: GPUCommandEncoder): void {
device.pushErrorScope("validation");
device.queue.submit([encoder.finish()]); // validated HERE
void device.popErrorScope().then((error) => {
// Resolves a frame or more later; never awaited inline.
if (error) console.error("frame submission invalid:", error.message);
});
}
Parameter reference
| Value | Setting here | Guidance |
|---|---|---|
| Granularity | one scope per phase | Per-call scopes are measurably slow at start-up and rarely narrow the search further. |
| Filter order | push validation, then OOM | Pop in reverse; the stack is last-in-first-out. |
| Frame scopes | around submit, not finish |
A command buffer is validated at submission. |
| Awaiting | never inline | popErrorScope resolves asynchronously; awaiting it in a frame serialises the pipeline. |
| Production | phases only, or off | Keep the safety net (uncapturederror) always; the scopes behind a flag. |
The three properties that surprise people
Scopes are a stack. pushErrorScope nests, and an error is captured by the innermost scope that filters its type. Popping out of order — awaiting the outer one first — resolves it before the inner one has finished, which silently changes which scope sees what.
They resolve asynchronously. popErrorScope returns a promise that settles when the implementation has finished validating the enclosed work, which for a submission means after the GPU has accepted it. Awaiting that inside a frame introduces exactly the CPU-GPU synchronisation the rest of the pipeline is designed to avoid; the frame-level form above deliberately does not await.
Recording is not validation. Most validation of a render or compute pass happens when the command buffer is submitted, not when the calls are recorded. A scope that closes after finish() and before submit() catches almost nothing, which is a common and confusing near-miss.
The safety net, and why it is not a strategy
device.addEventListener("uncapturederror", ...) catches anything no scope did, and it should always be installed — but it is a net rather than a plan, and the difference matters.
What the event gives you is the message. What it does not give you is the context: which phase, which resource, which frame. Handling it well means logging the message alongside whatever application state is available, and treating its appearance as a signal that a scope is missing somewhere rather than as the diagnostic itself.
The one place it is genuinely the right tool is production. Phase scopes cost something, and a shipped build that installs the event handler, tags the message with the current frame number and the capability record, and sends it to telemetry gets most of the value at none of the cost. A message from a user’s machine naming a labelled resource is often enough to fix a bug without reproducing it, which is the whole return on the discipline described here and on the labelling page.
The handler should never throw. An exception from inside it is unhandled, it interrupts nothing useful, and it can mask the error it was reporting.
Scoping a whole subsystem during a hunt
Phases are the right default and there is a case for going finer temporarily. When a phase scope reports an invalid buffer among sixty allocations, a per-call scope over just that phase narrows it in one run — and the cost, applied to one phase for one session, is irrelevant.
The way to make that switchable is to have the allocator itself take a flag. A createBuffer wrapper that pushes and pops a scope per call when a debug flag is set, and does nothing when it is not, turns the granularity into a runtime decision rather than an edit. The same wrapper is the natural place for the label convention, which means one function is doing both jobs.
That composition is worth arranging deliberately: an allocator that labels, optionally scopes, and counts is three diagnostics for the price of one indirection, and it is the single most useful piece of infrastructure in a WebGPU codebase of any size.
Failure modes
- A scope catches nothing and the bug is real. The failure is a logic bug producing valid API calls. Scopes have nothing to say about those; sentinels do.
uncapturederrorfires despite scopes being in place. The scope was popped before the work was validated — usually a frame scope closed atfinishrather than aftersubmit.- Start-up became noticeably slower. Per-call scopes. Move to phases.
- The error message names a resource you do not recognise. No labels. That is the other half of this discipline.
- A promise rejection appears with no stack.
popErrorScoperejects if the stack is empty — usually a pop without a matching push after an early return. Pop in afinally.
Reading a validation message
WebGPU validation messages are unusually good — they name the parameter, the value, the constraint and usually the object — and reading them literally saves more time than any tool on this page.
Three phrases recur. “…is not a multiple of…” is always an alignment problem, and the number quoted is the alignment the specification requires: 4 for an index buffer offset, 256 for a dynamic uniform offset, 256 for a buffer-sourced texture copy row. “…is smaller than the minimum binding size…” means a shader declares a struct larger than the buffer range bound to it, which is nearly always a struct that gained a field on one side and not the other. And “…is not compatible with…” between a pipeline and a bind group means the layouts differ, which for spatial code usually means a storage buffer declared read-only in the shader and read-write in the layout, or the reverse.
What the messages cannot tell you is which of several identical calls produced them, which is exactly the gap scopes and labels close. Used together the three form a complete answer: the message says what is wrong, the scope says which phase, and the label says which object.
Backend / Python interop note
Validation errors are client-side, but the payload frequently causes them, and there are two server-side properties that eliminate whole categories.
The first is a fixed record stride. Most alignment validation errors — “binding size is not a multiple of”, “offset is not aligned to” — come from a record whose size varies with the data rather than being fixed by the schema. Padding every record to a 16-byte multiple on the server, as the alignment rules require, makes the client’s buffer arithmetic exact by construction.
The second is a stated row count. A client that infers the element count from the byte length has to divide, and a payload whose length is not an exact multiple of the stride produces a truncated last element and a binding size that fails validation. Sending the count explicitly turns that into a clear mismatch at load rather than a validation error three calls later.
Where a Python service produces the payload, both are one-line properties of the writer, and both convert a class of confusing client-side error into a server-side assertion that fires during a build.
Related
- Debugging and validation for spatial GPU pipelines — the topic this page belongs to.
- Labeling GPU objects for readable spatial pipeline errors — what makes the messages usable.
- Diagnosing NaN coordinates in WGSL shaders — the failures no scope will ever catch.
- Memory alignment for spatial data buffers — the source of most validation errors in practice.
- Handling device lost and recreating GIS resources — the third error channel.