WebGPU for JavaScript Developers: A Practical Compute Example

WebGPU lets JavaScript send graphics and general-purpose compute work to a device's GPU. That can help with workloads that process many values in parallel, but it does not make every JavaScript task faster: setup, moving data, and reading results all have a cost.

This guide builds a small compute example that doubles 256 numbers. It shows how to request a GPU device, write a WGSL compute shader, dispatch workgroups, and read results back. The example is for learning the API, not a performance benchmark.

Check support before choosing WebGPU

WebGPU is available only in secure contexts, typically HTTPS. It is also not Baseline across browsers, so availability can differ by browser, operating system, and GPU. Check the current MDN WebGPU browser-compatibility data against the browsers and devices your users actually have.

Feature detection is not enough to guarantee that a usable GPU adapter is available. navigator.gpu can exist while requestAdapter() returns null, so handle both cases. If your application needs to draw on devices without WebGPU, consider a WebGL path; for small computations, a CPU implementation may be simpler.

How the compute path fits together

The browser exposes WebGPU through navigator.gpu. Call the MDN GPU.requestAdapter method reference to ask for an adapter, then the MDN GPUAdapter.requestDevice method reference to create a device your page can use. If you do not need optional GPU features or unusual limits, the default device request is a reasonable starting point.

JavaScript creates a compute pipeline from a WGSL shader and submits commands through the device's queue. The shader below writes into a GPU storage buffer. A second, readback buffer copies those values into memory JavaScript can map and inspect.

Double values in a compute shader

Serve this page from HTTPS (or a secure local development origin). The example reports unsupported browsers, missing adapters, shader compilation errors, and uncaptured WebGPU errors instead of assuming setup succeeded.

<pre id="output">Starting WebGPU example…</pre>
<script type="module">
const output = document.querySelector("#output");

async function run() {
  if (!navigator.gpu) {
    output.textContent = "WebGPU is not available in this browser.";
    return;
  }

  const adapter = await navigator.gpu.requestAdapter();
  if (!adapter) {
    output.textContent = "No WebGPU adapter is available on this device.";
    return;
  }

  const device = await adapter.requestDevice();
  device.addEventListener("uncapturederror", (event) => {
    output.textContent = `WebGPU error: ${event.error.message}`;
  });

  const count = 256;
  const byteLength = count * Float32Array.BYTES_PER_ELEMENT;
  const resultsBuffer = device.createBuffer({
    size: byteLength,
    usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
  });
  const readbackBuffer = device.createBuffer({
    size: byteLength,
    usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
  });

  const shader = device.createShaderModule({
    code: `
      @group(0) @binding(0)
      var<storage, read_write> results: array<f32>;

      @compute @workgroup_size(64)
      fn double_index(@builtin(global_invocation_id) invocation: vec3<u32>) {
        let index = invocation.x;
        if (index >= arrayLength(&results)) {
          return;
        }
        results[index] = f32(index) * 2.0;
      }
    `,
  });

  const compilation = await shader.getCompilationInfo();
  const shaderError = compilation.messages.find((message) => message.type === "error");
  if (shaderError) {
    throw new Error(shaderError.message);
  }

  const pipeline = await device.createComputePipelineAsync({
    layout: "auto",
    compute: {
      module: shader,
      entryPoint: "double_index",
    },
  });
  const bindGroup = device.createBindGroup({
    layout: pipeline.getBindGroupLayout(0),
    entries: [{ binding: 0, resource: { buffer: resultsBuffer } }],
  });

  const encoder = device.createCommandEncoder();
  const pass = encoder.beginComputePass();
  pass.setPipeline(pipeline);
  pass.setBindGroup(0, bindGroup);
  pass.dispatchWorkgroups(Math.ceil(count / 64));
  pass.end();
  encoder.copyBufferToBuffer(resultsBuffer, 0, readbackBuffer, 0, byteLength);
  device.queue.submit([encoder.finish()]);

  await readbackBuffer.mapAsync(GPUMapMode.READ);
  const values = Array.from(new Float32Array(readbackBuffer.getMappedRange()));
  readbackBuffer.unmap();

  output.textContent =
    `First values: ${values.slice(0, 8).join(", ")}\n` +
    `Values computed: ${values.length}`;
  resultsBuffer.destroy();
  readbackBuffer.destroy();
}

run().catch((error) => {
  output.textContent = `Could not run the WebGPU example: ${error.message}`;
});
</script>

The shader assigns each invocation a global index and writes twice that index into the storage array. A workgroup contains 64 invocations, so dispatching Math.ceil(256 / 64) starts four workgroups. The arrayLength guard keeps extra invocations from writing beyond the buffer if you later change the count to a value that is not a multiple of 64.

The result buffer uses STORAGE so the shader can write to it, and COPY_SRC so its contents can be copied. The readback buffer uses COPY_DST and MAP_READ; the copy is submitted to the queue before JavaScript maps the readback buffer. For this example, the first values should be 0, 2, 4, 6, 8, 10, 12, 14.

What to change for a real workload

  • Use the smallest required feature set. Request optional device features or higher limits only after checking that the adapter supports them. Extra requirements can prevent a device from being created on otherwise usable hardware.
  • Keep data on the GPU when possible. This sample reads results back to demonstrate the full path. Repeated CPU/GPU transfers can outweigh the computation for small jobs.
  • Size workgroups for the algorithm. The example uses 64 invocations as a teaching choice. Workgroup size and dispatch count should fit the algorithm and device limits; they are not a universal performance recommendation.
  • Plan for errors and device loss. The example catches rejected setup promises and reports uncaptured errors. Production code should also handle device loss and use validation error scopes where it needs to associate errors with a specific operation.
  • Keep a fallback where the product needs one. WebGPU is not available on every supported browser and device. A graphics feature can offer a WebGL fallback, while a compute feature may need a simpler CPU implementation or a clear unsupported message.

Use WebGPU when you have a measured, parallel workload or a graphics feature that benefits from modern GPU access. Start with a small slice, check compatibility on your target platforms, and compare the complete user-facing workflow—including setup and data transfers—before making it a requirement.

References

Leave a Reply