WebGPU: compute an array and draw a triangle in the browser
WebGPU lets a web page use the graphics processing unit (GPU) both to draw graphics and to perform general-purpose parallel computation, such as processing image pixels or updating particles in a simulation. JavaScript prepares data and submits work; small programs written in WebGPU Shading Language (WGSL) run on the GPU. These programs are called shaders, but a compute shader needs neither colors nor an image or canvas. MDN's WebGPU overview explains the separate roles of compute and render pipelines.
This guide sends [1, 2, 3, 4, 5] to the GPU, reads back [2, 4, 6, 8, 10], then draws a triangle in a separate page. You should understand basic JavaScript arrays and async/await, and have a browser, text editor, and local static server. No bundler, npm dependency, account, CUDA installation, or cloud API is needed.
To see a result first, start at “Run the array computation locally,” then return to the resource model. Both examples are standalone HTML files, not changes to a website component.
What work belongs on the GPU?
Central processing units (CPUs) are good at low-latency control logic, complex branches, and tasks with strong sequential dependencies. CPUs also have multiple cores and vector operations: “CPU means serial, GPU means parallel” is too simplistic. GPUs are useful when many execution units can perform similar operations over many elements. How much of that capacity a program uses depends on data size, memory access, and algorithmic dependencies.
Multiplying every array element by two has no dependencies between elements, so the operations can run concurrently. However, device creation, shader compilation, pipeline creation, uploads, scheduling, and readback all require work. For five multiplications, ordinary JavaScript is usually the more sensible choice. This example demonstrates a correct data flow, not acceleration.
Chrome's WebGPU introduction describes its relationship with modern native GPU APIs. Choosing a browser API or a native computing ecosystem starts with where the application needs to run, not an unmeasured performance ranking.
Check actual capabilities before browser versions
WebGPU requires a secure context. Usually this means HTTPS; for development, http://localhost:8000 and http://127.0.0.1:8000 are also potentially trustworthy local origins. An ordinary HTTP LAN address is not equivalent to localhost. This guide uses a local server rather than opening a file:// document by double-clicking it.
Check these in order:
- Is
window.isSecureContexttrue? - Does
navigator.gpuexist? - Does
requestAdapter()return a non-null adapter? - Does
adapter.requestDevice()complete successfully? - Do the resulting
device.featuresanddevice.limitsmeet the task's needs?
An adapter is the GPU capability entry point selected by the browser. A null result means no usable entry point was obtained; it does not by itself diagnose a broken driver. Browser configuration, runtime environment, hardware and drivers, requested capabilities, and resource conditions can affect availability. Catch device-creation failures too: the presence of an API property does not establish successful initialization. MDN's initialization guidance follows this sequence.
What official release records establish
These are documented shipping milestones from sources consulted on 2026-09-12, not a current support table covering every device:
- The Chrome overview records initial support in Chrome 113 on ChromeOS using Vulkan, Windows using Direct3D 12, and macOS. Its Android milestone is Chrome 121 on Android 12 or later with Qualcomm or ARM GPUs.
- The same overview lists Firefox 141 on Windows and the Safari 26 milestone. These statements do not establish availability on every Firefox platform or every Apple OS and device.
- The overview's Linux “coming soon” wording is older. Chrome 144's official update describes a careful Linux rollout starting with Intel Gen12 and newer GPUs, with WebGPU using Vulkan. That is a rollout report, not universal Linux GPU availability.
Do not guess your machine's capabilities from this historical list, or make experimental flags and weakened security the default solution. Runtime checks determine whether the current page can use WebGPU; a user-facing product also needs testing on its target browsers, operating systems, and devices.
Optional features and limits
adapter.features and adapter.limits describe capabilities that can be requested; device.features and device.limits describe what the created device exposes. Check optional features before including them in requiredFeatures. Use requiredLimits to request supported limits only when necessary. Unsupported requirements can make device creation fail; see the requestDevice() contract.
Do not request every feature and maximum limit. This example needs no optional features. As data grows, check buffer size, storage binding size, workgroup size, and dispatch limits as needed. Nor does every limit mean “larger is better”: maximum capacities and offset alignment requirements have different comparison semantics.
The objects you will meet in the code
The example follows this sequence: allocate → upload → bind → record computation → end the pass → record a copy → submit → await readback → release. JavaScript does not call a callback for each array element; one dispatch launches many WGSL invocations on the GPU. See the WebGPU API for the detailed object contracts.
Run the array computation locally
Save the complete content below as index.html in a new learning directory. Keep the .html extension rather than accidentally saving .html.txt. The example's status and error messages are in English to make comparison with the API documentation straightforward.
It uploads the input to a storage buffer, doubles values in place, then copies the result to a staging buffer dedicated to CPU readback. “Staging” means an intermediate transfer area, not another computation. On failure, the page explicitly labels a CPU fallback rather than presenting it as GPU output.
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>WebGPU: double an array</title>
<h1>WebGPU compute</h1>
<pre id="output" role="status">Starting…</pre>
<script type="module">
const output = document.querySelector('#output');
const input = new Float32Array([1, 2, 3, 4, 5]);
const WORKGROUP_SIZE = 64;
async function main() {
let device;
let storage;
let staging;
let scopeOpen = false;
let asyncFailure = null;
try {
if (!window.isSecureContext) {
throw new Error('Use HTTPS or a localhost static server.');
}
if (!navigator.gpu) {
throw new Error('WebGPU is unavailable in this browser/context.');
}
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
throw new Error('No usable WebGPU adapter was returned.');
}
device = await adapter.requestDevice();
device.lost.then(info => {
if (info.reason !== 'destroyed') {
asyncFailure = new Error(`GPU device lost: ${info.message}`);
output.textContent = asyncFailure.message;
}
});
device.addEventListener('uncapturederror', event => {
asyncFailure = new Error(event.error.message);
output.textContent = `GPU error: ${event.error.message}`;
});
device.pushErrorScope('validation');
scopeOpen = true;
storage = device.createBuffer({
label: 'In-place float data',
size: input.byteLength,
usage: GPUBufferUsage.STORAGE |
GPUBufferUsage.COPY_DST |
GPUBufferUsage.COPY_SRC,
});
staging = device.createBuffer({
label: 'CPU readback',
size: input.byteLength,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
});
device.queue.writeBuffer(storage, 0, input);
const module = device.createShaderModule({
label: 'Double each float',
code: `
@group(0) @binding(0)
var<storage, read_write> values: array<f32>;
@compute @workgroup_size(${WORKGROUP_SIZE})
fn main(@builtin(global_invocation_id) id: vec3<u32>) {
let i = id.x;
if (i >= arrayLength(&values)) {
return;
}
values[i] = values[i] * 2.0;
}
`,
});
const diagnostics = await module.getCompilationInfo();
const shaderErrors = diagnostics.messages.filter(m => m.type === 'error');
if (shaderErrors.length) {
throw new Error(shaderErrors.map(m =>
`${m.lineNum}:${m.linePos} ${m.message}`).join('\n'));
}
const pipeline = await device.createComputePipelineAsync({
layout: 'auto',
compute: {module, entryPoint: 'main'},
});
const bindings = device.createBindGroup({
layout: pipeline.getBindGroupLayout(0),
entries: [{binding: 0, resource: {buffer: storage}}],
});
const encoder = device.createCommandEncoder();
const pass = encoder.beginComputePass();
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindings);
pass.dispatchWorkgroups(Math.ceil(input.length / WORKGROUP_SIZE));
pass.end();
encoder.copyBufferToBuffer(storage, 0, staging, 0, input.byteLength);
device.queue.submit([encoder.finish()]);
const scopeResult = device.popErrorScope();
scopeOpen = false;
const validationError = await scopeResult;
if (validationError) throw new Error(validationError.message);
await staging.mapAsync(GPUMapMode.READ);
// Copy before unmap: the mapped ArrayBuffer is detached by unmap().
const result = new Float32Array(staging.getMappedRange().slice(0));
staging.unmap();
if (asyncFailure) throw asyncFailure;
const correct = result.every((value, i) => value === input[i] * 2);
if (!correct) throw new Error('Result verification failed.');
output.textContent =
`Input: ${JSON.stringify(Array.from(input))}\n` +
`GPU: ${JSON.stringify(Array.from(result))}\nVerified.`;
} catch (error) {
// Explicit educational fallback, never presented as a GPU result.
const cpu = input.map(value => value * 2);
output.textContent =
`WebGPU did not complete: ${error.message ?? String(error)}\n` +
`CPU fallback: ${JSON.stringify(Array.from(cpu))}`;
} finally {
if (scopeOpen && device) {
try { await device.popErrorScope(); } catch { /* Original error shown. */ }
}
if (staging?.mapState === 'mapped') staging.unmap();
staging?.destroy();
storage?.destroy();
// This is a one-shot demo. A real app normally keeps its device alive.
device?.destroy();
}
}
main();
</script>
</html>
Open a terminal in that directory. With Python 3 already installed, run:
python3 -m http.server 8000 --bind 127.0.0.1
An existing Windows Python installation may instead use py -3 -m http.server 8000 --bind 127.0.0.1. Another static server you already have is also fine; there is no need to install a dependency for this example. Bind only to loopback to avoid unintentionally exposing the directory to your LAN, and keep only the exercise files in it.
Open http://127.0.0.1:8000/. Successful completion should display:
Input: [1,2,3,4,5]
GPU: [2,4,6,8,10]
Verified.
If the port is occupied, replace 8000 with 8001 in both the command and address. If you see a directory listing, check the filename and the directory where you started the server. Stop the server with Ctrl+C when finished.
If the page displays WebGPU did not complete, the following message identifies the failure; CPU fallback only establishes that ordinary JavaScript produced a result. Check the address, secure context, browser, and device initialization first. Removing error handling does not fix an unavailable environment.
Why the buffers are created and read this way
Each Float32Array element occupies 4 bytes, so five elements occupy 20 bytes. WGSL's array<f32> uses the same contiguous layout here. JavaScript binding 0 matches WGSL's @group(0) @binding(0). layout: 'auto' is convenient for teaching one pipeline, but do not assume a bind group created from one automatic layout is reusable with unrelated pipelines.
The storage buffer has three distinct usages: STORAGE permits shader access; COPY_DST permits uploading with queue.writeBuffer(); and COPY_SRC permits copying the result out. The readback buffer only uses COPY_DST | MAP_READ. The WebGPU buffer rules do not allow combining MAP_READ directly with STORAGE on one buffer.
After mapAsync() completes, the CPU can access the mapped range. Here the offset is 0 and the size is 20 bytes, meeting the requirements that mapping offsets be multiples of 8 and sizes multiples of 4. Copy the mapped contents before unmap(), because unmapping invalidates the original mapped view.
Returning from submit() does not mean the GPU has finished. Awaiting mapAsync() is enough for this readback; adding queue.onSubmittedWorkDone() before it is unnecessary. The normal path reads before releasing buffers; the failure path aborts the task and releases resources. This one-shot page destroys its device at the end. A real application usually reuses a device instead of creating one for every batch.
Workgroup size is not dispatch count
@workgroup_size(64) declares 64 invocations along X per workgroup. dispatchWorkgroups(1) dispatches one such group, not one array element. Five elements therefore generate indices 0–63, and indices 5–63 must return at the bounds check.
For 1,000 elements, Math.ceil(1000 / 64) gives 16 workgroups and 1,024 invocations; the final 24 return. The size 64 is a teaching choice, not a universal optimum. Check maxComputeWorkgroupSizeX, maxComputeInvocationsPerWorkgroup, and maxComputeWorkgroupsPerDimension before choosing dispatch dimensions for larger tasks.
Each invocation writes only its own element, so no synchronization barrier is needed. Keep the input non-empty; a general utility should handle empty arrays on the CPU and check byte sizes and binding limits before allocating. Exact equality works for doubling these small integer-valued floats; general floating-point work needs problem-appropriate tolerances.
For a direct exercise, change the operation to values[i] = values[i] + 3.0. Also replace doubling with addition by three in the JavaScript verification and CPU fallback. The expected result is [4,5,6,7,8]. Changing only the shader makes the correctness check fail, which is precisely what a reference result should catch.
Draw a triangle next
A compute pipeline processes data; a render pipeline also writes to an image target. The following triangle.html is another complete standalone page. Save it in the same directory and open http://127.0.0.1:8000/triangle.html. It draws once and redraws on window resizing, without continuous animation or reading every frame's pixels back to the CPU.
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>WebGPU triangle</title>
<style>
canvas { display: block; width: min(90vw, 640px); height: 360px; }
</style>
<h1>WebGPU triangle</h1>
<pre id="status" role="status">Starting…</pre>
<canvas id="canvas" aria-label="A colored triangle on a dark background">
This example requires canvas support.
</canvas>
<script type="module">
const status = document.querySelector('#status');
const canvas = document.querySelector('#canvas');
let device;
let context;
let frame = 0;
let stopped = false;
let listening = false;
let scopeOpen = false;
function stop() {
stopped = true;
cancelAnimationFrame(frame);
if (listening) window.removeEventListener('resize', scheduleDraw);
context?.unconfigure();
device?.destroy();
}
function fail(error) {
status.textContent = `Cannot render with WebGPU: ${error.message ?? error}`;
stop();
}
let draw = () => {};
function scheduleDraw() {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
if (!stopped) {
try { draw(); } catch (error) { fail(error); }
}
});
}
window.addEventListener('pagehide', stop, {once: true});
async function main() {
try {
if (!window.isSecureContext || !navigator.gpu) {
throw new Error('Use a supporting browser over HTTPS or localhost.');
}
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) throw new Error('No usable GPU adapter.');
device = await adapter.requestDevice();
if (stopped) { device.destroy(); return; }
device.lost.then(info => {
if (info.reason !== 'destroyed') fail(new Error(`Device lost: ${info.message}`));
});
device.addEventListener('uncapturederror', event => fail(event.error));
context = canvas.getContext('webgpu');
if (!context) throw new Error('No WebGPU canvas context.');
const format = navigator.gpu.getPreferredCanvasFormat();
device.pushErrorScope('validation');
scopeOpen = true;
context.configure({device, format, alphaMode: 'opaque'});
const module = device.createShaderModule({code: `
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(0) color: vec3<f32>,
}
@vertex
fn vs(@builtin(vertex_index) index: u32) -> VertexOutput {
let positions = array<vec2<f32>, 3>(
vec2<f32>(0.0, 0.7),
vec2<f32>(-0.7, -0.7),
vec2<f32>(0.7, -0.7)
);
let colors = array<vec3<f32>, 3>(
vec3<f32>(1.0, 0.2, 0.2),
vec3<f32>(0.2, 1.0, 0.2),
vec3<f32>(0.2, 0.4, 1.0)
);
var result: VertexOutput;
result.position = vec4<f32>(positions[index], 0.0, 1.0);
result.color = colors[index];
return result;
}
@fragment
fn fs(input: VertexOutput) -> @location(0) vec4<f32> {
return vec4<f32>(input.color, 1.0);
}
`});
const pipeline = await device.createRenderPipelineAsync({
layout: 'auto',
vertex: {module, entryPoint: 'vs'},
fragment: {module, entryPoint: 'fs', targets: [{format}]},
primitive: {topology: 'triangle-list'},
});
const scopeResult = device.popErrorScope();
scopeOpen = false;
const error = await scopeResult;
if (error) throw new Error(error.message);
if (stopped) return;
draw = () => {
const ratio = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
const limit = device.limits.maxTextureDimension2D;
const desiredWidth = Math.max(1, Math.round(rect.width * ratio));
const desiredHeight = Math.max(1, Math.round(rect.height * ratio));
const scale = Math.min(1, limit / desiredWidth, limit / desiredHeight);
const width = Math.max(1, Math.floor(desiredWidth * scale));
const height = Math.max(1, Math.floor(desiredHeight * scale));
if (canvas.width !== width) canvas.width = width;
if (canvas.height !== height) canvas.height = height;
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
clearValue: {r: 0.03, g: 0.04, b: 0.08, a: 1},
loadOp: 'clear',
storeOp: 'store',
}],
});
pass.setPipeline(pipeline);
pass.draw(3);
pass.end();
device.queue.submit([encoder.finish()]);
};
window.addEventListener('resize', scheduleDraw);
listening = true;
draw();
status.textContent = 'Triangle submitted. Resize the window to redraw.';
} catch (error) {
fail(error);
} finally {
if (scopeOpen && device) {
try { await device.popErrorScope(); } catch { /* Failure shown above. */ }
}
}
}
main();
</script>
</html>
The expected image is a colored triangle on a dark background. Triangle submitted means commands were submitted; that message alone does not establish successful presentation. Without a usable GPU, the page shows a clear error. This rendering example does not implement a separate WebGL fallback.
Vertices, fragments, and the canvas
The vertex shader vs uses vertex_index to generate three vertex positions and colors. Positions use clip-space coordinates; with w = 1 here, X and Y from -1 to 1 span the target area. To make the geometry clear, the shader contains the three points directly, so no vertex buffer or bind group is needed.
Rasterization generates fragments for the triangle's covered area and interpolates vertex colors. The fragment shader fs returns the color at that location. A fragment is a candidate contribution during rendering, not invariably one final screen pixel. draw(3) makes the vertex stage process three vertices, and triangle-list assembles them into a triangle.
getPreferredCanvasFormat() returns the recommended canvas format for the current system. The pipeline's targets: [{format}] must match the canvas; do not hardcode one desktop's format. Obtain getCurrentTexture() again for each draw rather than permanently caching a presentation texture. The WebGPU canvas specification describes configuration and presentation texture lifetimes.
CSS dimensions determine page layout, while canvas.width and canvas.height determine the backing pixel dimensions. The code scales those dimensions by devicePixelRatio, then clamps them proportionally to maxTextureDimension2D. loadOp: 'clear' and clearValue clear the background; storeOp: 'store' preserves the result for presentation.
This fixed page listens only for window resizing. An embedded canvas in a complex layout also needs handling for element-size and pixel-ratio changes. The example cleans up its device on pagehide; restoration through the browser's back-forward cache requires reinitialization, or simply reload during the exercise. This one-shot page lifecycle is not a complete component framework.
Data layout: JavaScript arrays must match WGSL
The first example's scalar array needs no extra padding, but three-dimensional coordinates often expose layout mistakes. The WGSL alignment and size specification defines these storage element layouts, in bytes:
Size is the space occupied by the value itself; alignment constrains its starting address; stride is the distance between consecutive array element starts. Array stride rounds up to the element alignment, so each point in array<vec3<f32>> should occupy four host float slots, not three:
const points = new Float32Array([
1, 2, 3, 0, // x, y, z, padding
4, 5, 6, 0,
]);
The fourth slot is padding, not a fourth component of WGSL's vec3. A struct with two vec3f members has member offsets 0 and 16 and a total size of 32; see the WGSL array-layout examples. The uniform address space has additional rules, so storage packing cannot simply be reused for uniforms.
Struct member alignment, array stride, buffer binding offset alignment, and mapped-range alignment are different constraints. For strange values or validation failures, check layout and the actual binding range in bytes rather than only counting elements. Numerical code must also account for the precision difference between WGSL f32 and JavaScript's default Number.
Handle synchronization and errors before scaling up
A workgroup barrier synchronizes participating invocations within that workgroup; it cannot make all workgroups rendezvous. Algorithms needing communication between groups commonly split into multiple ordered dispatches. If you add workgroupBarrier(), do not retain a control flow where only some invocations return early and others reach the barrier without analyzing it. WGSL has uniformity requirements for barrier control flow; see WGSL uniformity analysis.
Do not keep a buffer mapped while expecting the GPU to use that same buffer. For repeated work, reuse pipelines and resources, keep intermediate data on the GPU, and read back only what the CPU needs. Multiple staging buffers can support more advanced overlapping schedules, but are not required for a correct introductory example.
Error scopes, pushErrorScope() / popErrorScope(), capture asynchronous GPU errors in the selected category; popErrorScope() returns the first matching error or null. The examples scope only validation, not every category such as out-of-memory and internal errors. An uncapturederror listener handles errors not captured by a scope. Ordinary JavaScript try/catch does not replace these mechanisms; see MDN's error-handling explanation.
device.lost can resolve even just after device creation. Recovery cannot reuse the old device's buffers, textures, bind groups, or pipelines. Calling device.destroy() intentionally also triggers loss notification. The code therefore skips reason === 'destroyed' rather than treating normal cleanup as a recovery request.
Destroy owned buffers and textures when no longer needed, and unmap mapped buffers. Pipelines and bind groups have no generic .destroy() method; release their references. The browser manages the canvas's current presentation textures, which are not application-allocated permanent textures.
How to determine whether it is faster
Start with a correct CPU reference and realistic input sizes. Separate at least two questions: how long a first page visit takes to produce a result, and how long one batch takes when the device, pipelines, and buffers are reused.
- Record device initialization and shader/pipeline creation separately from warm execution.
- Use the same timing boundaries for end-to-end comparisons, including required uploads, computation, copies, waits, readback, and result handling.
- Timing only around
queue.submit()primarily measures submission. To measure when JavaScript has a result in the first example, time throughawait mapAsync()and the result copy. - Repeat measurements and report variability, data size, browser, operating system, and GPU conditions. One observation does not establish general acceleration.
- Place correctness checks appropriately and state floating-point tolerances for numerical tasks. Do not omit necessary transfers merely to improve the reported time.
These recommendations follow from the submission and readback sequence, not from a speed guarantee. Optional timestamp-query can support more detailed GPU timing, but needs separate support checks and requests and cannot replace end-to-end measurements. Computing and then rendering directly without CPU readback is another useful workload; measure it separately from returning every result to JavaScript.
Suitable applications and further exercises
Image filters, large particle updates, structured numerical arrays, some simulation kernels, and browser-side machine-learning inference are worth evaluating with WebGPU. They may contain enough independent work to amortize scheduling and transfer costs. These are candidate workloads, not measured acceleration claims. Machine learning also requires checking the framework, operators, and target browsers; scientific computing requires checking precision, algorithm dependencies, memory capacity, and result validation first.
Small arrays, DOM operations, UI state, highly serial logic, and constant round-trips of tiny results usually fit ordinary JavaScript or CPU workers better. Consider native applications or servers when browser capabilities, memory, or client-device coverage do not fit. WebGPU does not directly manipulate the DOM or bypass the browser sandbox.
Site Lab provides existing experiments for comparison: Prism Dispersion uses batched p5.js WebGL rendering, ASCII Earth shows how a Three.js scene becomes characters, and the Rössler Attractor connects numerical integration with trajectory presentation. They are learning references, not implementations already converted to WebGPU.
Try extending the array example into independent particle updates, then let rendering consume the compute results directly to avoid per-frame readback. When comparing the Rössler model, preserve dependencies between time steps: the next step of one trajectory needs the previous result, so its time steps cannot simply be dispatched independently. Many trajectories with different initial conditions may instead provide another level of parallelism. For more development and project tools, see Tools & Workflows.