WebGPU Compute Readback: MAP_READ, Storage Buffers, and CPU Stalls
Read WebGPU compute results back to the CPU safely with STORAGE | MAP_READ buffers, onSubmittedWorkDone, and mapAsync.
03 Nov 2025, 08:36 UTC

You dispatched a compute shader. Now you need the numbers on the CPU.
WebGPU keeps most data on the GPU, but sometimes the CPU must read a compute result—a particle count, a physics query, or generated data for a file upload. The common mistake is creating a result buffer with only GPUBufferUsage.STORAGE. The shader can write to it, but the browser rejects mapAsync() because the CPU was never granted read access. The opposite mistake is mapping immediately after dispatchWorkgroups(): the GPU may still be running, so the mapped memory holds stale or unwritten values.
Takeaway: create the readback buffer with GPUBufferUsage.STORAGE | GPUBufferUsage.MAP_READ, submit the compute dispatch, wait for the queue to finish, then map and read. Call unmap() when you are done so the buffer can be reused.
1. Storage versus uniform buffers for read-only data
If the CPU only feeds read-only data into a shader, you have two binding types. A uniform buffer holds small constants that shaders read but do not write. A storage buffer is larger and shaders can read and write it. Limits differ by device, so read them from device.limits instead of assuming a number.
| Binding type | Typical use | Size limit to check |
|---|---|---|
| Uniform buffer | Read-only constants, matrices, material parameters | device.limits.maxUniformBufferBindingSize (often 16 KB–64 KB) |
| Storage buffer | Read-write or large read-only datasets | device.limits.maxStorageBufferBindingSize (usually much larger) |
Practical rule: if your read-only dataset fits the uniform limit, a uniform buffer is fine. If it exceeds that limit, or if the shader writes results the CPU must read, use a storage buffer with MAP_READ for the readback copy.
2. Safe CPU readback example
Run this in a browser page or DevTools console on a secure origin (HTTPS or localhost). WebGPU is unavailable in insecure contexts. The placeholders below are not real values; replace them with your adapter, device, pipeline, and bind group.
if (!navigator.gpu) {
throw new Error('WebGPU is not available in this browser');
}
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
throw new Error('No WebGPU adapter found');
}
const device = await adapter.requestDevice();
const bufferSize = 1024; // bytes; adjust to your data
const readbackBuffer = device.createBuffer({
size: bufferSize,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.MAP_READ,
});
// Create your compute pipeline and bind group here, binding
// readbackBuffer as a storage buffer. and
// are placeholders for your own objects.
// const pipeline = ...;
// const bindGroup = ...;
const commandEncoder = device.createCommandEncoder();
const pass = commandEncoder.beginComputePass();
pass.setPipeline();
pass.setBindGroup(0, );
pass.dispatchWorkgroups(1);
pass.end();
device.queue.submit([commandEncoder.finish()]);
// Ensure the GPU has finished writing before the CPU maps the buffer.
await device.queue.onSubmittedWorkDone();
// mapAsync resolves when the buffer is mapped for CPU access.
await readbackBuffer.mapAsync(GPUMapMode.READ);
const mappedData = new Float32Array(readbackBuffer.getMappedRange(0, bufferSize));
// Inspect results. The shader should have written known values here.
console.log(mappedData);
// Release the mapping so the buffer can be reused or destroyed.
readbackBuffer.unmap();
Where to run: browser JavaScript context with WebGPU enabled. Permissions: none beyond standard feature detection; no special OS privileges. Expected check: navigator.gpu exists, requestAdapter() returns an adapter, and mappedData contains the values your shader wrote. Risk: calling getMappedRange() before mapAsync() resolves is invalid and can throw or return garbage. Omitting unmap() can block buffer reuse and cause validation errors on later operations.
3. Limitations and how to verify
GPU-to-CPU readback is a synchronization point. The CPU waits for the GPU, and the GPU may wait for the CPU, which stalls the pipeline. Do not read back every frame in an interactive application. Keep data on the GPU when possible, and batch readbacks if you must move data to the CPU.
Browser support is uneven. WebGPU ships by default in Chrome and Edge on desktop and Android. Safari and Firefox support depends on the version and may still be behind flags. Before shipping, confirm navigator.gpu exists in each target browser, read device.limits for your binding sizes, and run a minimal dispatch that writes a known pattern—such as the workgroup index into each element—then read it back and assert the values match.
Closing
For WebGPU compute readback, use STORAGE | MAP_READ, wait on onSubmittedWorkDone(), map asynchronously, read the range, and call unmap(). Choose storage buffers for large read-only datasets or any buffer the CPU must read back; reserve uniform buffers for small constants that fit the device limit. Verify adapter availability and limits in each target browser before assuming the code will run.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.