10LOC
Chrome APIsadvanced

A minimal WebGPU compute shader

Published January 1, 2027

// No @webgpu/types in this repo's tsconfig, so these casts stand in for the
// missing ambient types. Swap them for the real types in a project that has
// @webgpu/types installed.
export const doubleOnGpu = async (input: Float32Array): Promise<Float32Array> => {
  const gpu = (navigator as any).gpu;
  const { GPUBufferUsage, GPUMapMode } = globalThis as any;
  const adapter = await gpu.requestAdapter();
  const device = await adapter.requestDevice();

  const dataBuffer = device.createBuffer({
    size: input.byteLength,
    usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC | GPUBufferUsage.COPY_DST,
  });
  device.queue.writeBuffer(dataBuffer, 0, input);

  const module = device.createShaderModule({
    code: `
      @group(0) @binding(0) var<storage, read_write> data: array<f32>;
      @compute @workgroup_size(64)
      fn main(@builtin(global_invocation_id) id: vec3<u32>) {
        if (id.x < arrayLength(&data)) {
          data[id.x] = data[id.x] * 2.0;
        }
      }
    `,
  });
  const pipeline = device.createComputePipeline({ layout: "auto", compute: { module, entryPoint: "main" } });
  const bindGroup = device.createBindGroup({
    layout: pipeline.getBindGroupLayout(0),
    entries: [{ binding: 0, resource: { buffer: dataBuffer } }],
  });

  const encoder = device.createCommandEncoder();
  const pass = encoder.beginComputePass();
  pass.setPipeline(pipeline);
  pass.setBindGroup(0, bindGroup);
  pass.dispatchWorkgroups(Math.ceil(input.length / 64));
  pass.end();

  const readBuffer = device.createBuffer({
    size: input.byteLength,
    usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
  });
  encoder.copyBufferToBuffer(dataBuffer, 0, readBuffer, 0, input.byteLength);
  device.queue.submit([encoder.finish()]);

  await readBuffer.mapAsync(GPUMapMode.READ);
  const result = new Float32Array(readBuffer.getMappedRange().slice(0));
  readBuffer.unmap();
  return result;
};

What

WebGPU's GPUDevice can run a compute pipeline: a kernel written in WGSL (WebGPU's shading language) dispatched across a grid of GPU threads that each run the same function over a slice of a buffer — general-purpose parallel compute, with no triangle or pixel involved.

Why it matters

WebGL never got compute shaders — it's a rendering API descended from OpenGL ES 2/3, and anything compute-shaped (particle simulation, image filters, physics) had to be smuggled through fragment shaders writing to an off-screen texture, which is exactly as awkward as it sounds. WebGPU is a from-scratch API modeled on Vulkan/Metal/D3D12, and general compute is a first-class citizen alongside rendering: no framebuffer required, just a buffer in, a kernel, a buffer out.

The reason to reach for this at all is throughput. A CPU loop processes an array one element at a time on one core. The snippet's kernel runs the same handful of WGSL lines across thousands of GPU lanes simultaneously — for embarrassingly-parallel, per-element work, that's where the order-of-magnitude speedup actually shows up, not on small arrays where the round trip to the GPU costs more than the computation saves.

How it works

doubleOnGpu doubles every element of a Float32Array on the GPU and reads the result back:

  • navigator.gpu.requestAdapter() then adapter.requestDevice() get a GPUDevice — the entry point for everything else. Both can resolve to null/reject if WebGPU isn't available or no adapter meets requested limits; production code should check.
  • dataBuffer is created with STORAGE usage (so the shader can read/write it), COPY_SRC/COPY_DST (so the CPU can write into it and later copy out of it), then device.queue.writeBuffer uploads the input.
  • The WGSL string declares one @compute @workgroup_size(64) function. @builtin(global_invocation_id) gives each of the dispatched threads its own index; the if (id.x < arrayLength(&data)) guard matters because dispatchWorkgroups always dispatches whole workgroups — a 100-element array with workgroup_size(64) dispatches 2 full workgroups (128 threads), and the extra 28 threads need to no-op rather than touch memory past the buffer's logical length.
  • layout: "auto" on createComputePipeline infers the bind group layout straight from the WGSL — convenient for a single shader, see Gotchas for when that stops being enough.
  • The compute pass itself is four calls: setPipeline, setBindGroup, dispatchWorkgroups(Math.ceil(input.length / 64)) (one workgroup per 64 elements, rounded up), end().
  • STORAGE buffers can't be mapped for reading directly, so the encoder copies dataBuffer into a separate MAP_READ buffer before submission. mapAsync then exposes that buffer's contents as an ArrayBuffer; .slice(0) copies it out because the mapped range is invalidated the moment unmap() runs.

Gotchas

  • No polyfill exists for missing compute support — feature-detect navigator.gpu and fall back to a CPU path (or skip the feature) rather than assuming it's there.
  • layout: "auto" can't be shared across multiple pipelines that need the same bind group layout — for anything beyond a one-shader demo, define a GPUBindGroupLayout explicitly instead.
  • This repo's tsconfig.json doesn't include @webgpu/types (WebGPU still isn't part of TypeScript's bundled DOM lib), so the snippet casts navigator and reads GPUBufferUsage/GPUMapMode off globalThis as any to typecheck here. Install @webgpu/types in a real project and drop the casts for full IntelliSense.
  • GPU work is asynchronous end-to-end but the errors aren't always where you'd expect — a malformed WGSL shader fails at createShaderModule time asynchronously via the device's error scopes/uncapturederror event, not by throwing synchronously at the call site.

Browser support: Chrome/Edge 113+ (stable since 2023). Safari shipped WebGPU in Safari 26 (macOS Tahoe, iOS/iPadOS 26) in 2026. Firefox is mid-rollout — Windows since Firefox 141, Apple Silicon macOS since Firefox 145 — and not yet on by default everywhere. Treat WebGPU as a progressively-enhanced capability, feature-detected at the call site, not a baseline dependency, until Firefox's rollout finishes.

Related

  • WebGL2 — still the right choice for straightforward rendering; it has no compute-shader equivalent.
  • Web Workers / WASM SIMD — the CPU-bound parallel option when WebGPU isn't available or the dataset is too small to justify a GPU round trip.