Running Parallel Work in the Browser with WebGPU Compute Shaders
Learn how to run parallel workloads in the browser using WebGPU compute shaders and WGSL, with a grayscale image filter example and practical guidance on workgroup sizing and readback stalls.
28 Mar 2026, 00:49 UTC

When you need to process large amounts of data in the browser—think image filters, particle simulations, or reductions over big arrays—the traditional route has been to abuse WebGL fragment shaders or fall back to slow JavaScript loops. Both approaches hide the true parallel nature of the GPU behind awkward APIs or extra copies. WebGPU changes that by exposing a explicit compute pipeline that lets you write straightforward, massively parallel code in WGSL (the WebGPU Shading Language).
From problem to pipeline: the core steps
The mental model is small but precise:
- Obtain a
GPUDevicefrom aGPUAdapter. - Compile a WGSL module that contains an
@computefunction with a defined@workgroup_size. - Create
GPUBufferobjects for inputs and outputs, marking them with the correct usage flags (STORAGE,COPY_DST,COPY_SRC, etc.). - Bind those buffers into a
GPUBindGroupthat matches the layout declared in the shader. - Record a compute pass, set the bind group, and call
dispatchWorkgroupswith the appropriate grid size. - Submit the command buffer and, when you need the result, map a read‑back buffer with
mapAsync.
Each step is explicit, which means the validation layer can catch mismatched usages or layout errors early and report them in the console.
Worked example: a grayscale filter
Below is a compact, self‑contained example that converts an RGBA image to grayscale using a compute shader. The code assumes you are running in a recent Chrome or Edge where navigator.gpu is available.
// 1. Request adapter and device
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) throw new Error('WebGPU not supported');
const device = await adapter.requestDevice();
// 2. Prepare image data (for demo, create a 256x256 RGBA Uint8Array)
const width = 256, height = 256;
const input = new Uint8Array(width * height * 4);
// fill with a simple gradient so we can see the effect
for (let i = 0; i < input.length; i += 4) {
input[i] = (i / 4) % 256; // R
input[i + 1] = ((i / 4) * 2) % 256; // G
input[i + 2] = ((i / 4) * 3) % 256; // B
input[i + 3] = 255; // A
}
// 3. Create GPU buffers
const inputBuffer = device.createBuffer({
size: input.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_DST,
});
const outputBuffer = device.createBuffer({
size: input.byteLength,
usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
});
device.queue.writeBuffer(inputBuffer, 0, input);
// 4. WGSL shader
const shaderCode = `
@group(0) @binding(0) var input : array;
@group(0) @binding(1) var output : array;
@compute @workgroup_size(256)
fn main(@builtin(global_invocation_id) id: u32) {
let idx = id * 4;
if (idx + 3 >= arrayLength(&input)) return; // bounds‑check
let r = input[idx];
let g = input[idx + 1];
let b = input[idx + 2];
// luminance formula
let gray = u32(floor(0.2126 * f32(r) + 0.7152 * f32(g) + 0.0722 * f32(b)));
output[idx] = gray;
output[idx + 1] = gray;
output[idx + 2] = gray;
output[idx + 3] = input[idx + 3]; // preserve alpha
}
`;
const shaderModule = device.createShaderModule({ code: shaderCode });
// 5. Bind group layout and bind group
const bindGroupLayout = device.createBindGroupLayout({
entries: [
{ binding: 0, visibility: GPUShaderStage.COMPUTE, buffer: { type: "read-only-storage" } },
{ binding: 1, visibility: GPUShaderStage.COMPUTE, buffer: { type: "storage" } },
],
});
const bindGroup = device.createBindGroup({
layout: bindGroupLayout,
entries: [
{ binding: 0, resource: { buffer: inputBuffer } },
{ binding: 1, resource: { buffer: outputBuffer } },
],
});
// 6. Create pipeline
const pipeline = device.createComputePipeline({
layout: device.createPipelineLayout({ bindGroupLayouts: [bindGroupLayout] }),
compute: { module: shaderModule, entryPoint: "main" },
});
// 7. Record and submit work
const commandEncoder = device.createCommandEncoder();
const pass = commandEncoder.beginComputePass();
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindGroup);
const workgroupSize = 256;
const workgroupsX = Math.ceil((width * height) / workgroupSize);
pass.dispatchWorkgroups(workgroupsX);
pass.end();
device.queue.submit([commandEncoder.finish()]);
// 8. Read back result (asynchronous)
const readbackBuffer = device.createBuffer({
size: outputBuffer.size,
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
});
const copyEncoder = device.createCommandEncoder();
copyEncoder.copyBufferToBuffer(outputBuffer, 0, readbackBuffer, 0, outputBuffer.size);
device.queue.submit([copyEncoder.finish()]);
await readbackBuffer.mapAsync(GPUMapMode.READ);
const result = new Uint8Array(readbackBuffer.getMappedRange());
// At this point `result` holds the grayscale image; you can copy it to a canvas or texture.
readbackBuffer.unmap();
The shader uses a workgroup of 256 invocations. The dispatch calculates workgroupsX = ceil(pixelCount / workgroupSize) so every pixel is covered, and the first line of the kernel guards against out‑of‑range access.
Trade‑offs and practical limits
Workgroup size. Picking 256 works well on many desktop GPUs, but the optimal number depends on the hardware’s warp/wavefront size and the amount of shared memory you might need later. Too small a size leads to under‑utilisation; too large can exceed the device’s maxComputeWorkgroupSize or cause register spilling. Always query adapter.limits.maxComputeWorkgroupSize and maxComputeWorkgroupSizePerDimension to stay within bounds.
Readback stalls. Mapping a buffer for MAP_READ forces the GPU to finish all pending work and copies the data back to CPU memory, which can dominate frame time if done every frame. In real‑time applications keep the data on the GPU (e.g., as a texture for the next render pass) and only map when you truly need the result.
How to verify and iterate
- Open
chrome://gpu(or the Edge equivalent) and confirm that WebGPU is enabled and which backend (Metal, Vulkan, D3D12) is active. - Introduce a deliberate error—for example, change the input buffer usage to
GPUBufferUsage.COPY_SRConly—and watch the validation message appear in the DevTools console. - Time the compute pass with
performance.now()before and after the submit, then compare runs with and without the readback step to see the impact ofmapAsync. - Experiment with different
@workgroup_sizevalues (e.g., 64, 128, 256) and measure the dispatch time; ensure the bounds‑check in the shader still prevents overflow.
By following these steps you can confidently move from a WebGL‑based hack to a clean, portable compute solution that runs directly in the browser.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.