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.

When a scope has to close Two lanes compare two scope placements around a frame. In the early arrangement the scope is pushed before recording and popped after finish, so it closes before the command buffer is submitted and catches almost nothing, because most pass validation happens at submission. In the correct arrangement the scope spans the submit call, so the validation the implementation performs when it accepts the command buffer is inside the scope and is attributed to that frame. SCOPE PLACEMENT · RECORD vs SUBMIT Closed too early Correct push scope before recording record + finish little validated yet pop, then submit errors escape the scope record + finish outside the scope push, then submit validated here pop without awaiting logs a frame later Never await the pop inside the frame — it resolves after the GPU has accepted the work.
A command buffer is validated when it is submitted. A scope that closes between finish and submit is a near-miss that catches nothing and looks like the code is clean.

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.

typescript
/** 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.

typescript
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.
Scope granularity against start-up cost A bar chart in milliseconds of added start-up time for three scope granularities in an application that creates about 240 GPU resources. No scopes at all is the baseline of zero. One scope per phase, about eight phases, adds roughly 1.2 milliseconds. One scope per resource adds roughly 91 milliseconds, which is a visible delay at start-up for a localisation benefit that phases mostly already provide. ADDED START-UP COST · ms No scopes 0 ms Per phase (8) 1.2 ms Per resource (240) 91 ms 0 20 40 60 80 100 ms cheap visible at start-up Modelled proportions; the shape holds because each scope costs a fixed amount of tracking.
Phases cost almost nothing and narrow a failure to a handful of lines. Per-resource scopes are a tool for the ten minutes when a phase is not narrow enough, not a default.

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.
  • uncapturederror fires despite scopes being in place. The scope was popped before the work was validated — usually a frame scope closed at finish rather than after submit.
  • 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. popErrorScope rejects if the stack is empty — usually a pop without a matching push after an early return. Pop in a finally.
What each scope filter catches A table of three error scope filters with what each captures and what to do about it. The validation filter captures descriptors that do not satisfy the specification, bindings that do not match a layout and misaligned offsets, and the response is to fix the code. The out-of-memory filter captures allocations the device cannot satisfy, and the response is to shed resolution or evict resources rather than to change code. The internal filter captures driver-side failures, most often a shader too complex to compile, and the response is to simplify the shader. SCOPE FILTER · CAPTURES · RESPONSE Captures Response "validation" bad descriptors, bad bindings fix the code "out-of-memory" allocation refused shed or evict "internal" driver failure simplify the shader Push all three around a phase and pop in reverse — the stack is last-in-first-out.
The middle row is not a bug and treating it as one wastes time. An out-of-memory error on a machine with less VRAM is the resource policy speaking, and the fix is in the budget rather than in the code.

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.