From WebGL's state machine to WebGPU's explicit pipelines
WebGPU is finally shipping in every major browser engine, but it isn't WebGL with a new name. Here's what changed in the design, why, and how to ship it without breaking users who don't have it.
Open a WebGL codebase that's a few years old and you'll find something odd. Before almost every draw call, the code re-binds a texture, re-sets a blend mode, re-enables a vertex array. Half of it looks redundant. It isn't. Somewhere else, a helper might have changed that state, and the draw call will happily use whatever it finds.
Now WebGPU is supported in all major browsers. So you plan to swap one API for another, and find out that almost nothing lines up. There's no gl.bindTexture. There's no gl.enable. Drawing a triangle takes more code than it ever did before.
That isn't an accident. WebGPU throws away the model WebGL inherited from OpenGL and replaces it with the one VulkanKhronos's cross-platform, low-level GPU API for native apps on Windows, Linux and Android. Read more ↗, MetalApple's low-level GPU API for macOS, iOS and its other platforms. Read more ↗ and Direct3D 12Microsoft's low-level GPU API for Windows and Xbox, part of DirectX 12. Read more ↗ use. This post walks through what that model looked like, what replaced it, and what the browser support situation really is as of late October 2026.
The state machine WebGL inherited
OpenGL, which dates back to 1992, treats the GPU as a giant bag of global settings. You bind a buffer, bind a texture, pick a shader program, set the blend mode, and then call a draw function. The draw call doesn't take any of that as arguments. It just uses whatever happens to be set at that moment.
WebGL 1.0 (2011) is OpenGL ES 2.0 exposed through a <canvas>, and WebGL 2.0 (2017) is OpenGL ES 3.0. Both kept that model. A draw looks like this.
gl.useProgram(program);
gl.bindVertexArray(vao);
gl.activeTexture(gl.TEXTURE0);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.drawArrays(gl.TRIANGLES, 0, 3);
Every one of those calls mutates a single context object. That's friendly when you're starting out, but it has four costs that get worse as scenes grow.
First, the driver can't know what you intend until the draw call arrives, so it re-validates the whole combination of bound state on every draw. It sometimes has to compile a hidden shader variant right then, which shows up as a hitch mid-frame.
Second, the state is global. If a helper library leaves a texture bound or a blend mode switched on, your next draw is wrong, and nothing in your code says why. That's why tools like Spector.jsA WebGL debugging tool, as a browser extension or embeddable script, that captures a frame so you can inspect every call and state change. Read more ↗ exist, and why three.js and Babylon.js keep their own state caches.
Third, one context belongs to one thread, so you can't spread command recording across cores.
Fourth, there's no compute stage. Anything general-purpose had to be disguised as rendering, which I'll get to in a moment.
On the web it's worse than native, because the browser validates your calls, ships them to a separate GPU process, translates them through ANGLEAn open-source layer browsers use to implement WebGL by running OpenGL ES calls on Direct3D, Metal or Vulkan. Read more ↗ into Direct3D, Metal or Vulkan, and only then reaches the driver. Each layer adds CPU overhead.
What WebGPU borrowed from Vulkan, Metal and Direct3D 12
Between 2014 and 2016, Apple's Metal, Microsoft's Direct3D 12 and Khronos's Vulkan all flipped the model. Shaders and most fixed-function state get baked into an immutable pipeline object. Resources are declared up front through explicit binding layouts. Commands are recorded into buffers that you submit to a queue later, possibly from many threads.
WebGPU is the intersection of those three, filtered through one question. Can the browser validate this cheaply and safely? That's why it takes Metal's friendliness (one queue, render passes with load and store operations) and the pipeline and bind group layouts from Vulkan and D3D12, but leaves out manual memory heaps, manual barriers and multiple queues. The implementation tracks how each resource is used within a pass and inserts the barriers itself. Exposing raw barriers would make validation intractable.
The shading language got the same treatment. After a long standoff between Apple (a safe HLSLHigh-Level Shading Language, Microsoft's shader language for Direct3D. Read more ↗ variant) and Google and Mozilla (SPIR-VKhronos's binary intermediate format for shaders, used by Vulkan and OpenCL. Read more ↗ binaries), the group landed on WGSL, a text language that maps cleanly to SPIR-V, MSLMetal Shading Language, Apple's C++-based language for writing Metal shaders. Read more ↗ and HLSL while staying readable. It has no preprocessor, almost no implicit conversions, and safe, bounded behavior for things like out-of-bounds access, so the source can be fully analyzed before any driver sees it.
Recording work instead of mutating state
Here's the same kind of draw in WebGPU.
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
clearValue: [0, 0, 0, 1],
loadOp: "clear",
storeOp: "store",
}],
});
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindGroup);
pass.draw(3);
pass.end();
device.queue.submit([encoder.finish()]);
There's still some state, but it lives inside the pass (the current pipeline and bind groups). Nothing leaks between passes. Each pass also declares its attachments and what to do with them at the start and end, which maps directly onto tile-basedGPUs that render the screen in small tiles held in fast on-chip memory, the common design on phones. Read more ↗ mobile GPUs that need to know whether to load or store tile memory.
The real change is where the work moved. The blend mode, culling, depth test, vertex layout and color formats all live inside the GPURenderPipeline you created earlier, along with the shader modules. Creating it validates everything once and compiles the shaders once, and createRenderPipelineAsync pushes that off the critical path entirely. A GPUBindGroup does the same for resources. It's an immutable set of buffers, textures and samplers matching a layout, so switching a whole material is one setBindGroup call.
So the cost didn't vanish. It moved from every draw call to creation time, and the developer now decides when to pay it. You also take on jobs the driver used to do quietly. You organize bind groups (a common split is per frame, per material, per object), manage upload and readback buffers, and call destroy() on big buffers and textures because the garbage collector can't see GPU memory pressure.
The trade-off is rigidity. Bind groups are static, and the lack of bindless resourcesLetting shaders index into large arrays of textures and buffers directly, instead of binding a fixed set before each draw. is the most requested missing feature. The WebGPU working group has a draft proposal (a resizable GPUResourceTable) and Dawn has an experimental extension, but neither is in the spec or shipped. Khronos describes bindless as being in an early-to-mid design stage.
CPU access to GPU memory changed shape too. In WebGL, readPixels is a synchronous readback that stalls until the GPU catches up. In WebGPU, reading data back means copying into a mappable buffer and awaiting buffer.mapAsync(). A synchronous map would block the page for an unbounded time while another process finishes the work, which is the same single-thread problem described in How JavaScript's event loop really works. The awaited readback isn't free, though. One early 2026 preprint (since withdrawn) measured it eating roughly 22% of its per-token time in an LLM pipeline, so you keep data on the GPU and read back only the final result.
Compute is the real new thing
Rendering gets the attention, but the biggest functional gap between the two APIs is that WebGL has no compute shaders. Before WebGPU, people did GPU computing by hiding data in textures. You packed arrays into RGBA texels, drew a full-screen quad into a framebuffer, and let the fragment shader compute one output pixel per element. Simulations ping-ponged between two textures. It worked, but you had no scatter writes, no shared memory, no atomics, and readback was awkward.
WebGPU compute passes work on plain storage buffers and storage textures, share fast on-chip workgroup memory, and support atomics. The same buffer can carry STORAGE | VERTEX | INDIRECT usages, so a compute pass can simulate particles and a render pass can draw them straight from the same memory with no CPU round trip. It can even write the draw arguments for the next pass, which is how GPU-driven cullingThe GPU itself decides which objects are visible and writes the draw commands, so the CPU never inspects each object. works.
Running a compute pass and reading the result back looks like this.
const encoder = device.createCommandEncoder();
const pass = encoder.beginComputePass();
pass.setPipeline(computePipeline);
pass.setBindGroup(0, bindGroup);
pass.dispatchWorkgroups(Math.ceil(count / 64));
pass.end();
encoder.copyBufferToBuffer(output, 0, staging, 0, output.size);
device.queue.submit([encoder.finish()]);
await staging.mapAsync(GPUMapMode.READ);
const result = new Float32Array(staging.getMappedRange().slice(0));
staging.unmap();
The compute pass writes into a storage buffer, a copy moves the result into a mappable staging buffer, and mapAsync resolves only once the GPU is done with it.
That's also why in-browser ML moved to WebGPU. WebLLM, ONNX Runtime Web and Transformers.js all use it. In the WebLLM paper, an M3 Max in Chrome Canary decoded Llama-3.1-8B at about 71% of native MLC-LLM speed on Metal. Treat that as a best case. The same research literature shows a naive, dispatch-heavy workload landing far below native, because every dispatch carries a fixed API cost that adds up.
It's not bare-metal access
A common misconception is that WebGPU gives pages direct, low-level GPU access. It doesn't. Every call is validated and proxied to a sandboxed GPU process, because GPU drivers need system calls that web content shouldn't have and tend to crash or hang. Buffers and textures are zero-initialized so a page can't read stale memory from another process. If a driver dies, the GPU process takes the hit and your page gets a device.lost promise resolving instead of a crashed browser.
WGSL is also recompiled by Tint (Chrome), Naga (Firefox and Deno) or WebKit's own compiler, with bounds checks injected along the way. That makes the shader compiler a front-line security boundary. Chromium's own research notes this is especially sensitive on Android, where the GPU process isn't sandboxed while it parses shader input.
Where WebGPU actually runs today
"Supported in all major browsers since November 2025" is true per browser and misleading per platform. As of late October 2026, here's the picture.
- Chrome and Edge on Windows, macOS and ChromeOS since version 113, and on Android 12+ with ARM, Qualcomm and Intel GPUs since 121.
- Chrome on Linux only on Intel Gen12 and newer, and on recent NVIDIA drivers under Wayland. Most other configurations still need flags or aren't supported, and Windows on ARM is still behind a flag too.
- Safari 26 on macOS, iOS, iPadOS and visionOS. Since nearly every iOS browser uses WebKit, that covers every iPhone and iPad running iOS 26. Older iOS versions have nothing.
- Firefox on Windows (since 141) and Apple Silicon Macs. Linux, Android and Intel Macs aren't covered yet, and Mozilla's stated goal of shipping on Linux in 2026 hadn't happened by Firefox 159.
Two more things are worth knowing. Chrome 146 shipped Compatibility Mode, an opt-in, slightly restricted WebGPU that runs on OpenGL ES 3.1 and covers devices where the full Vulkan-based path isn't available. You request it with featureLevel: "compatibility". Because it's a strict subset, an app built for it is also a valid full WebGPU app. And "shipped" doesn't always mean fast. One early 2026 preprint (since withdrawn by its author) measured around 1 ms of overhead per dispatch in Firefox, compared with roughly 32 to 66 microseconds in Safari and Chrome, which makes dispatch-heavy workloads impractical there today. The author says the cause is unconfirmed.
WebGL 2, by contrast, is available on more than 96% of browsers according to Khronos. Khronos itself calls WebGPU "a successor to WebGL (not a replacement)".
Falling back safely
Detection has to be layered, because navigator.gpu can exist while no usable GPU does. WebGPU also requires a secure context, so plain HTTP quietly falls back.
async function pickRenderer() {
if (!("gpu" in navigator)) return "webgl2";
// Can be null even when navigator.gpu exists (blocklisted GPU, no support)
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) return "webgl2";
const device = await adapter.requestDevice();
device.lost.then(() => {
// Rebuild on a new device, or switch to WebGL 2 mid-session
});
return { adapter, device };
}
Figma designed for the mid-session case on purpose. Failures can show up after a session has started, so they built a fallback that can move from WebGPU to WebGL after the fact. If you'd rather not maintain two renderers, the libraries already do this. three.js WebGPURenderer falls back to WebGL 2 automatically, and writing materials in TSL (three.js Shading Language) means one node graph that compiles to WGSL or GLSL. Babylon.js, PlayCanvas and Unity 6.6 all keep a WebGL path as well.
FAQ
Is WebGPU replacing WebGL?
Not yet, and the standards bodies don't frame it that way. Khronos calls WebGPU a successor and keeps adding WebGL extensions. Every major engine still ships a WebGL 2 path because it runs nearly everywhere.
Is WebGPU faster than WebGL?
It can be, mostly because validation and compilation happen once instead of per draw. Babylon.js reported roughly 10x for a mostly static scene using render bundlesPrerecorded, reusable lists of WebGPU render commands that can be replayed in later passes with little CPU cost. Read more ↗, but that's a vendor figure for one scene, and the gain is on the CPU side. Compute-heavy work gains the most, since WebGL has no real equivalent.
Can I use WebGPU outside the browser?
Yes. Dawn (C++) and wgpu (Rust) implement it natively, webgpu.h is the shared C header (declared stable in September 2025), and Deno ships it in the runtime (behind a flag in some versions). It's still missing hardware ray tracing, mesh shaders, bindless resources and multi-queue submission, so it isn't a full substitute for Vulkan, Metal or D3D12 in AAA engines.
Should I drop WebGL 2 now?
No. Firefox gaps on Linux, Android and Intel Macs, plus Chrome on most Linux hardware, are real. Build on WebGPU through a framework with an automatic WebGL 2 fallback, and keep that fallback until your own telemetry says you can remove it.
Why is the shader language new?
Because the browser needs to fully validate shaders before a driver sees them. WGSL has no preprocessor, strong typing and safe out-of-bounds behavior, and it translates to SPIR-V, MSL and HLSL. Existing GLSL needs conversion, and the usual route is automated, through glslangKhronos's reference GLSL compiler, which turns GLSL shaders into SPIR-V. Read more ↗ or Naga. Expect some manual fixes too. Textures and samplers are separate objects in WGSL, clip-space depth runs from 0 to 1 instead of -1 to 1, and derivatives must sit in uniform control flow.
Wrapping up
WebGPU didn't make the GPU faster. It changed who decides when the expensive work happens. WebGL lets you mutate state freely and charges the driver on every draw. WebGPU makes you build pipelines and bind groups up front, record commands, and submit them, so that cost is paid once.
If you're starting a web graphics project now, use a framework that targets WebGPU with a WebGL 2 fallback, and check requestAdapter() for null before you trust navigator.gpu. If you want to learn the model itself, write one render pass and one compute pass by hand before reaching for a library. The two go together, and that pairing is where the new design earns its keep.