For 12 years, WebGL has been the only serious way to talk to a GPU from a browser. It powered Google Earth in a tab, the New York Times' election visualizations, Sketchfab portfolios, Shadertoy experiments, real-estate walkthroughs on Matterport – a whole class of experiences that simply don't make sense outside the web. You hit a URL, the thing loads, you spin it around, and when you're done you close the tab. No app store, no install, no commitment.
But WebGL is old. It's based on OpenGL ES 2.0 and 3.0, which descend from an API that shipped in 1993, back when "state of the art real-time graphics" looked like a flat-shaded fighter jet. The hardware underneath has changed beyond recognition. Native APIs evolved with it – Vulkan, Direct3D 12, Metal – and WebGL has been left squeezing modern GPUs through a 30-year-old straw.
In April 2023, Google shipped WebGPU in Chrome 113 on Windows, macOS, and ChromeOS. Firefox and Safari are actively working on their own implementations. It is, without exaggeration, the biggest thing to happen to graphics on the web since WebGL itself.
This article is a tour of what WebGPU actually is, why it looks the way it does, and what changes for the people building on the web.
Why not just "Web Vulkan"?
The obvious question whenever a new web graphics API appears is: why invent one? Vulkan already exists. Couldn't the browser just expose that?
In theory, sure. In practice, no.
Vulkan is enormous. There's a (only slightly exaggerated) chart floating around comparing the lines of code needed to draw a single triangle in different APIs – OpenGL 1 fits on a napkin, D3D11 is reasonable, D3D12 is chunky, and Vulkan's bar just keeps going off the edge of the page. That verbosity exists for good reasons in native land: engine vendors like Unreal and Unity told Khronos they wanted access to every knob on every piece of hardware, and Khronos delivered.
The web has different priorities:
- Write once, run anywhere. Code you ship should at least function on a low-end Android phone and a desktop RTX card. Performance will differ; behavior shouldn't.
- Safe by default. No undefined behavior. No reading memory you don't own. No surprise access to hardware the user didn't consent to. This is non-negotiable.
- Opt-in to anything that breaks portability. Optional features and higher limits exist, but you have to ask for them explicitly.
Native APIs were designed for engineers who want maximum control over a known target. Web APIs are designed for an unknown target with a hostile threat model. Those are different jobs. WebGPU is the compromise: a modern API that mirrors the shape of Vulkan / D3D12 / Metal closely enough to be efficient, while leaving out the parts the browser can't safely expose.
The trade-off is roughly: more complex than WebGL, dramatically simpler than Vulkan.
What you actually get
A short list of the things WebGL couldn't really do, and WebGPU can:
- Compute shaders. The headline feature. You can finally run general-purpose GPU workloads without pretending they're textures.
- Render bundles. Pre-recorded chunks of render commands you can replay each frame, skipping a huge amount of CPU-side overhead.
- Indirect drawing. Draw arguments that live in GPU buffers rather than being uploaded from JS.
- External textures for cheap video decoding into the render pipeline.
- Real debugging. Object labels, debug groups, and error messages that point at the thing that's actually broken.
- No global state. If you've ever fought a WebGL bug because some third-party library left the depth test in the wrong mode, this alone is worth the upgrade.
A walk through the API
The fastest way to get a feel for WebGPU is to follow what it takes to put a single triangle on screen. The shape will be familiar if you've used any modern graphics API, and that's intentional – WebGPU isn't trying to invent new concepts, just package the existing ones for the browser.
1. Adapter and device
Everything starts with an adapter, which represents a physical GPU (or a software fallback). You request one from navigator.gpu, optionally hinting that you want a low-power or high-performance device.
const adapter = await navigator.gpu.requestAdapter({ powerPreference: "high-performance" });const device = await adapter.requestDevice();
The device is your main handle. You'll create every buffer, texture, shader, and pipeline through it. It also gives you a queue for actually executing work on the GPU.
Two important concepts live on the adapter: features and limits. Features are optional capabilities (specific texture compression formats, timestamp queries, etc.). Limits are numeric ceilings (max texture size, max buffer binding size, and so on). Every WebGPU implementation guarantees a baseline. If you want anything above that baseline, you have to put it in requiredFeatures / requiredLimits when requesting the device. Forget to opt in, and the feature is invisible even if the hardware supports it. This is the "no accidental incompatibility" rule in action.
2. The canvas context
In WebGL, the context is the API. In WebGPU, the context's only job is to hand out textures to render into:
const context = canvas.getContext("webgpu");
context.configure({
device,
format: navigator.gpu.getPreferredCanvasFormat(),
alphaMode: "premultiplied",
});
That getPreferredCanvasFormat() call matters more than it looks. Desktop platforms generally prefer bgra8unorm; mobile prefers rgba8unorm. Use the preferred format and the compositor displays your texture directly. Use the other one and you eat a full-screen copy every frame. Free performance – just ask.
3. Buffers and the queue
You upload vertex data via a GPUBuffer:
const vertices = new Float32Array([0.0, 0.5, 0, -0.5, -0.5, 0, 0.5, -0.5, 0, ]);
const vertexBuffer = device.createBuffer({
size: vertices.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(vertexBuffer, 0, vertices);
The buffer's size and usage flags are fixed at creation. The contents aren't. Need a different size? Make a new buffer and drop the old one – they're cheap.
queue.writeBuffer is the helper everyone underestimates. It looks like a convenience function, but it's also one of the most efficient ways to push small-to-medium updates to the GPU. Don't avoid it on principle.
4. Shaders in WGSL
WebGPU ships its own shading language, WGSL (WebGPU Shading Language). The syntax leans Rust-ish:
@vertexfn vs(@location(0) pos: vec3f) - > @builtin(position) vec4f {
return vec4f(pos, 1.0);
}
@fragmentfn fs() - > @location(0) vec4f {
return vec4f(1.0, 0.2, 0.3, 1.0);
}
WGSL has been the most controversial part of WebGPU. The recurring question is "why not just use GLSL / HLSL / SPIR-V?" The honest answer is that no existing language was going to drop in unchanged. The browser has to translate shaders to whatever the underlying driver speaks (Metal, D3D12, Vulkan), and it needs to enforce web-grade safety rules that none of those source languages assume. Any "GLSL on the web" would have been almost GLSL, with a list of asterisks – and a permanent dependency on someone else's language committee. Owning the language means WebGPU can evolve at its own pace.
If you're a small developer, it's a language. You'll learn it in a weekend. If you're a big studio, you're already cross-compiling your shaders for multiple targets and WGSL is just one more output.
5. The render pipeline
The pipeline is the single most complex object in the API. It bundles the shader stages, vertex layout, output formats, primitive topology, blending, depth-stencil state, multisampling – most of what would have been mutable global state in WebGL.
const pipeline = device.createRenderPipeline({
layout: "auto",
vertex: {
module: shaderModule,
entryPoint: "vs",
buffers: [{
arrayStride: 12,
attributes: [{
shaderLocation: 0,
offset: 0,
format: "float32x3"
}],
}],
},
fragment: {
module: shaderModule,
entryPoint: "fs",
targets: [{
format: navigator.gpu.getPreferredCanvasFormat()
}],
},
primitive: {
topology: "triangle-list"
},
});
Most fields have sensible defaults, but you'll find yourself coming back to tweak pipelines more than anything else.
6. Encode, submit, draw
Drawing happens through a command encoder that records a sequence of commands, then hands you an opaque command buffer to submit:
const encoder = device.createCommandEncoder();
const view = context.getCurrentTexture().createView();
const pass = encoder.beginRenderPass({
colorAttachments: [{
view,
clearValue: {
r: 0,
g: 0,
b: 0,
a: 1
},
loadOp: "clear",
storeOp: "store",
}],
});
pass.setPipeline(pipeline);
pass.setVertexBuffer(0, vertexBuffer);
pass.draw(3);
pass.end();
device.queue.submit([encoder.finish()]);
Every render pass starts with a clean slate of state. Nothing leaks across passes. Nothing leaks across frames. If you've ever spent an evening hunting down which library re-enabled gl.SCISSOR_TEST, you'll appreciate this.
Bind groups: the part that bites you
Everything that isn't a vertex attribute or a render attachment – uniforms, storage buffers, textures, samplers – reaches your shader through bind groups. Bind groups are the area most newcomers find confusing, so it's worth slowing down.
You declare bindings in the shader, grouped by @group(n) @binding(m):
@group(0) @binding(0) var<uniform> camera: Camera;@group(1) @binding(0) var albedo: texture_2d<f32>;@group(1) @binding(1) var samp: sampler;
On the JS side, you describe matching bind group layouts, combine them into a pipeline layout, then create concrete bind groups that point at real resources. At draw time, you call setBindGroup(index, group) for each group the pipeline uses.
You can skip most of that boilerplate by passing layout: "auto" when creating a pipeline. It works. But explicit layouts let you share bind groups across pipelines, which unlocks a much better way to organize your renderer: group bindings by how often they change.
A good mental model:
- Group 0 – frame-level data (camera, time, global lighting). Set once per frame.
- Group 1 – material data (textures, material params). Set once per material.
- Group 2 – object data (transform, skinning). Set per draw.
If you design your pipelines around that structure, every frame turns into "set group 0, then for each material set group 1, then for each mesh set group 2 and draw." State changes drop, performance goes up. State changes are what kill GPU throughput; this is the single biggest lever you have.
Debugging that actually helps
WebGL's idea of an error message was INVALID_OPERATION. Good luck.
WebGPU does two things to fix this. First, every object can have a label:
const vertexBuffer = device.createBuffer({
label: "player vertices",
size: vertices.byteLength,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
Second, you can wrap groups of commands in named debug groups:
encoder.pushDebugGroup("main render loop");
encoder.pushDebugGroup("render scene"); // ... encoder.popDebugGroup(); encoder.pushDebugGroup("render UI"); // ... encoder.popDebugGroup();encoder.popDebugGroup();
When something goes wrong, the error message includes both the labels of the offending objects and the debug-group stack that led to the failure: "buffer 'player vertices' usage doesn't include VERTEX, while encoding setVertexBuffer in 'main render loop' → 'render scene'."
These labels also flow all the way down to native tools like RenderDoc and PIX. They have essentially zero runtime cost. There is no reason not to label everything from day one. You'll thank yourself the first time something breaks.
Performance habits to build early
A handful of patterns that pay back disproportionately:
Minimize pipelines. Pipeline switches are expensive everywhere, not just on the web. As a reference point, id Software has said Doom Eternal uses around 50 pipelines for the entire game. You won't hit that number, but the direction of travel is right: dedupe materials, share shaders, build pipelines from a small set of templates.
Create pipelines asynchronously. WebGPU lets you create a pipeline and use it immediately, but the actual shader compile happens on a background path. If you draw with a pipeline before it's ready, the queue stalls and your frame hitches – the same "shader stutter" digital-foundry types complain about in native games. Use createRenderPipelineAsync / createComputePipelineAsync and await the result before drawing. On Chrome, this is also currently the only way to get multi-threaded shader compilation, which can be a 4× speedup in pipeline-heavy workloads.
Use render bundles for static work. Render bundles are pre-recorded slices of a render pass that you can replay with a single command. They don't make the GPU faster, but they slash the CPU and JS-to-GPU-process overhead. Babylon.js's "snapshot rendering" mode, which is built on render bundles, can take a scene from 200 fps to 2000 fps on the same hardware. If you're CPU-bound – and in JavaScript, you often are – this is the biggest single win available to you.
Use the preferred canvas format. Already mentioned, worth repeating. One line of code, free fill-rate.
Where this is heading
It's worth being clear about the state of things at time of writing. WebGPU is shipped in Chrome 113 on Windows, macOS, and ChromeOS. Android is in the pipeline. Firefox (via wgpu) and Safari are both implementing it; the work is real and the will is there, but I won't put dates on other people's roadmaps. Expect availability to broaden over the coming months, not years.
WebGPU also isn't only a web thing. Chrome's implementation is built on Dawn (a C++ library); Firefox's is built on wgpu (Rust). Both are usable directly from native code, and there are community bindings for Python, Zig, and others. For the first time in a while, there's a credible candidate to fill the cross-platform niche OpenGL used to occupy – modern, smaller surface area, actually maintained.
On the roadmap, post-1.0: ray tracing, mesh shaders, cooperative matrices for ML workloads, lower-precision compute. Nothing is committed to a specific version, but the direction is clear, and the standards process is unusually collaborative across vendors.
What to do with it
If you're a hobbyist: open the WebGPU Samples, pick the one closest to what you want to build, read the shader source, change a number, see what happens. That's still the fastest way to learn a graphics API.
If you're shipping something real: don't write your own engine unless writing engines is the point. Babylon.js and Three.js both have solid WebGPU backends. Filament has a WebGPU path. TensorFlow.js has a WebGPU backend that can run models entirely on the client, which opens up a class of privacy-preserving, low-latency ML features that weren't practical before.
If you're a web developer who has never touched a GPU: this is a great moment to start. WebGPU is meaningfully easier to learn than Vulkan or D3D12, gives you a real compute shader pipeline, and the things you build live at a URL – which is still, after all these years, the lowest-friction way to put a piece of interactive software in front of another human being.
The web spent the last decade learning what it could do with a graphics API designed for 2005. We're about to find out what it can do with one designed for now.
About the Author (Bio):
Gleb Khmyznikov is a Software Development Engineer 2 at Microsoft with over 10 years of experience in web development, developer tooling, and the Progressive Web App ecosystem. He builds developer-facing tools at Microsoft – most notably PWABuilder – driving its adoption far beyond targets while expanding into developer tools on Windows and early native Python/ML enablement on Windows on Arm in partnership with Arm, Qualcomm, and 10+ internal teams. He is the creator of the widely adopted open-source PWA Install component, with millions of downloads, and previously served as Lead Web Developer at Hostme, where he built an entire real-time restaurant management platform from scratch and pioneered its move to a single installable cross-platform PWA. A recognized community voice, public speaker, and an active mentor in the global developer community.
LinkedIn: https://www.linkedin.com/in/glebkhmyznikov-130511128
Comments
Loading comments…