Parallel Array Processing with WebGPU Storage Buffers
Use WebGPU compute shaders and read-write storage buffers to process arrays on the GPU, then copy results back to JavaScript.
03 Oct 2026, 14:09 UTC

The problem: array math without blocking the main thread
You have a large array of numbers—audio samples, pixel intensities, or simulation values—and you need to transform every element. Running the loop on the CPU blocks the main thread, and a WebGPU render pass is overkill because you are not drawing anything. The useful takeaway is that WebGPU compute shaders with read-write storage buffers let you process data entirely on the GPU and copy the result back to JavaScript only when you need it.
Why storage buffers fit compute work
A storage buffer is a region of GPU memory that a compute shader can both read and write. Unlike textures, which are optimized for image sampling, storage buffers hold plain structured data such as arrays of floats or integers. A compute pipeline runs without a render pass: you create a GPUComputePipeline from a WGSL shader module, then call dispatchWorkgroups on a GPUCommandEncoder to launch groups of GPU threads.
A workgroup is a block of threads that execute together. You declare its size in WGSL with @workgroup_size(x, y, z), and JavaScript computes how many workgroups to launch by dividing your array length by that size and rounding up.
Buffer lifecycle and CPU readback
To use a storage buffer:
- Create a GPUBuffer with GPUBufferUsage.STORAGE.
- Upload data, typically with mappedAtCreation or writeBuffer.
- Bind it to the compute pipeline at a @group/@binding slot.
- Dispatch the compute pass.
- Copy the storage buffer to a second GPUBuffer with GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST.
- Call mapAsync on the readback buffer and read the resulting ArrayBuffer.
STORAGE buffers cannot be mapped directly by JavaScript, so step five is required. The copy is a GPU-to-GPU operation; mapAsync then brings the data into CPU memory asynchronously.
Worked example: doubling every element
Run this in a browser console or module on a secure context, such as HTTPS or localhost. WebGPU is unavailable on older browsers and some headless environments.
// WGSL shader module
@group(0) @binding(0)
var values: array;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
let i = id.x;
if (i < arrayLength(&values)) {
values[i] = values[i] * 2.0;
}
}
// JavaScript: device setup, dispatch, and readback
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 input = new Float32Array([1, 2, 3, 4, 5]);
const byteLength = input.byteLength;
// GPU storage buffer: readable and writable by the shader
const storageBuffer = device.createBuffer({
size: byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
mappedAtCreation: true,
});
new Float32Array(storageBuffer.getMappedRange()).set(input);
storageBuffer.unmap();
// Separate buffer for CPU readback; STORAGE buffers cannot be mapped directly
const readbackBuffer = device.createBuffer({
size: byteLength,
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
});
// Create a GPUComputePipeline from the WGSL module above, then a bind group
// that binds storageBuffer at @binding(0).
const pipeline = /* your compute pipeline */;
const bindGroup = /* your bind group */;
const encoder = device.createCommandEncoder();
const pass = encoder.beginComputePass();
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindGroup);
pass.dispatchWorkgroups(Math.ceil(input.length / 64));
pass.end();
encoder.copyBufferToBuffer(storageBuffer, 0, readbackBuffer, 0, byteLength);
device.queue.submit([encoder.finish()]);
await readbackBuffer.mapAsync(GPUMapMode.READ);
const output = new Float32Array(readbackBuffer.getMappedRange());
console.log(output); // the buffer should contain doubled values
readbackBuffer.unmap();
Notes:
- navigator.gpu.requestAdapter() returns null if the browser or GPU driver lacks Vulkan, Metal, or Direct3D 12 support.
- WGSL syntax can differ between specification drafts and browser releases; verify arrayLength and other built-ins against your target browser.
- dispatchWorkgroups(Math.ceil(input.length / 64)) matches the @workgroup_size(64) declared in the shader.
Limits and when to avoid this path
Readback is not free. Copying data to CPU and calling mapAsync introduces latency, so this pattern suits batch processing rather than per-frame updates at 60 fps. Storage buffers are not directly mappable, so you must maintain a separate readback buffer. WebGPU is unavailable in some headless environments and older browsers. Finally, workgroup size and dispatch counts must align with the shader declaration; the shader guards against out-of-bounds invocations with an if check, but mismatched counts can still waste GPU work.
Quick verification checklist
- Open DevTools in Chrome 113+, Edge 113+, Safari 17+, or a recent Firefox desktop release.
- Run !!navigator.gpu. If false, WebGPU is not available.
- Run await navigator.gpu.requestAdapter(). If null, no compatible GPU backend was found.
- Run the compute snippet above and inspect the mapped readback buffer. If values are unchanged, check the bind group layout, buffer usage flags, and workgroup count.
If the device is lost during execution, re-create the adapter, device, buffers, and pipeline. There is no persistent state to roll back.
Closing
Start with a small array and a single workgroup. Once the readback round-trip works, scale the dispatch count and batch size. Storage buffers are most valuable when the data stays on the GPU across multiple dispatches; avoid copying to CPU between every step unless your application genuinely needs the result in JavaScript.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.