WebGPU in 2026: What It Is and Your First Triangle in JavaScript
WebGPU is the modern GPU API for the web - faster than WebGL, built for compute. Here's what it actually is and how to draw your first triangle in vanilla JS.
What WebGPU Actually Is (and Why It Took So Long)
WebGPU shipped stable in Chrome 113 back in 2023, but 2026 is the year it genuinely matters - Firefox shipped it unflagged in 2025, and Safari's implementation finally caught up to spec. So if you've been waiting for cross-browser coverage before learning it, the wait is over.
The short version: WebGPU is a JavaScript API that talks to your GPU through the OS-native graphics layer - Metal on macOS, Vulkan on Linux and Android, DX12 on Windows. It replaces WebGL, which was basically a thin wrapper around OpenGL ES 2.0 - an API from 2007 that was never designed for the web. WebGL works, sure, but you're constantly fighting it.
Honestly, the mental model shift is more significant than the API surface. WebGL is stateful and imperative - you bind things, you draw, you unbind things, and if you forget a step you get a black screen with zero useful error messages. WebGPU is explicit and structured. You describe what you want upfront in pipeline objects, command buffers, and bind group layouts, and the driver validates it once at creation time rather than exploding at draw time.
Worth noting: WebGPU also ships a compute shader model. That means you can run general-purpose GPU programs - machine learning inference, physics simulations, image processing - entirely in the browser without WebAssembly or a server. That's the part the ML folks are excited about. But let's start with the triangle.
Core Concepts Before You Touch Code
You need four mental buckets before any code makes sense: the adapter and device, buffers, pipelines, and render passes. Skip the concepts and you'll be copy-pasting without understanding why anything works.
The GPUAdapter represents the physical GPU. The GPUDevice is your logical connection to it - think of it like a database connection. You request both asynchronously at startup. From the device you create everything else. One more thing - the adapter request can return null if there's no GPU available or if the user's browser doesn't support WebGPU, so you need that check or you'll get confusing runtime errors.
Buffers (GPUBuffer) are blocks of memory on the GPU. You upload vertex data, index data, uniform data - all of it goes into buffers. Unlike WebGL's gl.bufferData chaos, WebGPU buffers are created with an explicit usage flag bitmask so the driver knows upfront how you'll use them. A buffer flagged GPUBufferUsage.VERTEX can't be used as a uniform without re-creating it. Strict, yes. But it means no silent performance cliffs.
A render pipeline (GPURenderPipeline) is a compiled description of your draw call: which shaders, which vertex layout, which blend modes, which depth settings, which output format. You create it once, it's immutable. The GPU driver compiles the shaders at creation time, not at draw time. That's why games do their loading screens - they're compiling pipelines. In WebGPU you feel this too: the first createRenderPipeline call can take 10–30ms. Cache your pipelines.
Render passes are where drawing actually happens. You get a GPURenderPassEncoder from a command encoder, you set pipeline and bind groups, you draw, you end the pass, you submit the command buffer. Everything between beginRenderPass and end() is recorded and sent to the GPU as a batch. That batching is where the performance gains come from.
WGSL: The Shader Language You Need to Know
WebGPU uses WGSL (WebGPU Shading Language), not GLSL. If you're coming from WebGL or Three.js shaders, you'll notice the syntax is closer to Rust than C. It's strongly typed, has no implicit conversions, and the tooling is still maturing - but it's readable once you've spent 20 minutes with it.
A minimal vertex shader in WGSL looks like this:
``wgsl
@vertex
fn vs_main(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4<f32> {
var positions = array<vec2<f32>, 3>(
vec2<f32>( 0.0, 0.5),
vec2<f32>(-0.5, -0.5),
vec2<f32>( 0.5, -0.5),
);
let p = positions[vi];
return vec4<f32>(p.x, p.y, 0.0, 1.0);
}
@fragment
fn fs_main() -> @location(0) vec4<f32> {
return vec4<f32>(0.4, 0.2, 1.0, 1.0);
}
`
Both entry points live in the same string here. The @builtin(vertex_index)` pulls the current vertex index so you don't even need a buffer for a hardcoded triangle - positions live directly in the shader array.
In practice, you'd separate vertex data into a GPU buffer once you move past hello-world. The inline array trick is useful for debugging and demos but not for anything with dynamic geometry. Quick aside: WGSL's vec4<f32> verbose syntax is getting a vec4f shorthand that landed in the spec in 2025 and is already valid in Chrome 120+.
The fragment shader is deliberately dead simple here - just returns a solid purple (roughly rgb(102, 51, 255)). Once the pipeline compiles and you see that flat-shaded triangle on screen, you'll know your entire WebGPU setup is working end-to-end. Start here, add complexity after.
Full Working Triangle: The Complete JavaScript Setup
Here's the whole thing in one file. This runs in any browser that supports WebGPU - paste it into an HTML file and open it in Chrome or Firefox 2025+:
``html
<!DOCTYPE html>
<html>
<body>
<canvas id="c" width="600" height="600"></canvas>
<script type="module">
const canvas = document.getElementById('c');
const context = canvas.getContext('webgpu');
if (!navigator.gpu) {
document.body.textContent = 'WebGPU not supported';
throw new Error('no webgpu');
}
const adapter = await navigator.gpu.requestAdapter();
const device = await adapter.requestDevice();
const format = navigator.gpu.getPreferredCanvasFormat();
context.configure({ device, format });
const shaderCode =
@vertex
fn vs(@builtin(vertex_index) vi: u32) -> @builtin(position) vec4f {
var pos = array<vec2f,3>(
vec2f(0.0, 0.5), vec2f(-0.5,-0.5), vec2f(0.5,-0.5)
);
return vec4f(pos[vi], 0.0, 1.0);
}
@fragment
fn fs() -> @location(0) vec4f {
return vec4f(0.4, 0.2, 1.0, 1.0);
}
;
const module = device.createShaderModule({ code: shaderCode });
const pipeline = device.createRenderPipeline({
layout: 'auto',
vertex: { module, entryPoint: 'vs' },
fragment: {
module,
entryPoint: 'fs',
targets: [{ format }]
},
primitive: { topology: 'triangle-list' }
});
function frame() {
const encoder = device.createCommandEncoder();
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: context.getCurrentTexture().createView(),
clearValue: { r: 0.05, g: 0.05, b: 0.08, a: 1 },
loadOp: 'clear',
storeOp: 'store'
}]
});
pass.setPipeline(pipeline);
pass.draw(3);
pass.end();
device.queue.submit([encoder.finish()]);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>
</body>
</html>
``
A few things worth calling out. navigator.gpu.getPreferredCanvasFormat() returns either 'bgra8unorm' or 'rgba8unorm' depending on the platform - always use this instead of hardcoding the format. Getting it wrong means your colors are swapped on some systems. layout: 'auto' tells WebGPU to infer the bind group layout from the shader, which saves a lot of boilerplate when you're not using any uniforms or textures.
The render loop calls context.getCurrentTexture() every frame to get the current swap chain texture. That's your canvas's drawable surface. You create a view from it, attach it as a color attachment with a clear operation, and that's your render pass target. The clearValue here is a dark near-black - { r: 0.05, g: 0.05, b: 0.08, a: 1 } - which makes the purple triangle pop.
Look, 60 lines is more than a WebGL triangle, but that's deceptive. The WebGL version would be 60 lines too - it's just that with WebGL you'd be hiding half the setup in helper functions from day one because the raw API is unusable without them. The WebGPU 60 lines is the real setup, not a simplified version.
Uploading Vertex Data from JavaScript Buffers
Hardcoding positions in the shader gets old fast. Here's how you push actual vertex data from JavaScript into a GPU buffer and read it in WGSL:
``js
const vertices = new Float32Array([
// x y
0.0, 0.5,
-0.5, -0.5,
0.5, -0.5,
]);
const vertexBuffer = device.createBuffer({
size: vertices.byteLength, // 24 bytes for 6 floats
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
});
device.queue.writeBuffer(vertexBuffer, 0, vertices);
`
Then your shader needs an explicit input struct instead of @builtin(vertex_index):
`wgsl
struct VertexIn {
@location(0) pos: vec2f,
};
@vertex
fn vs(in: VertexIn) -> @builtin(position) vec4f {
return vec4f(in.pos, 0.0, 1.0);
}
`
And the pipeline needs a buffers array describing the layout:
`js
vertex: {
module,
entryPoint: 'vs',
buffers: [{
arrayStride: 8, // 2 floats × 4 bytes = 8 bytes per vertex
attributes: [{
shaderLocation: 0,
offset: 0,
format: 'float32x2'
}]
}]
}
`
In the render pass: pass.setVertexBuffer(0, vertexBuffer);`
The arrayStride is the number of bytes per vertex. Here it's 8 - two 32-bit floats at 4 bytes each. If you add a color attribute later (another vec2f or vec4f), you'd increase arrayStride to 16 or 24 and add a second entry in attributes. The explicit stride is exactly the kind of thing WebGL would silently mishandle; WebGPU throws a validation error at pipeline creation time instead.
Worth noting: GPUBufferUsage.COPY_DST is required when you upload data via writeBuffer. If you forget it, the validation layer will catch it - another example of WebGPU failing loud and early instead of producing a blank screen with no error.
Once you're managing your own vertex buffers, you're ready to move into indexed geometry, uniforms for transforms, and eventually textures. The concepts stack linearly. If you're building UI components on top of WebGPU output - say, overlaying controls - check out how Empire UI handles glassmorphism components for the kind of layered visual hierarchy that works well alongside canvas-based rendering.
What Comes Next: Uniforms, Textures, and Compute
After the triangle, the natural next steps are: uniforms for per-draw data (transform matrices, time values), textures and samplers for image data, and eventually compute shaders for GPU-accelerated processing. Each one adds a bind group entry and a matching WGSL binding declaration - the pattern is consistent once you've done it once.
Uniforms in WebGPU live in GPUBuffer objects flagged UNIFORM | COPY_DST. You write to them with device.queue.writeBuffer every frame (or whenever the data changes). A 4×4 matrix is 64 bytes. Your WGSL accesses it through a @group(0) @binding(0) declaration and a var<uniform> variable. The layout: 'auto' shortcut still works here - WebGPU infers the bind group layout from the shader bindings automatically.
Compute shaders are a different entry point entirely - @compute @workgroup_size(64) instead of @vertex or @fragment. You dispatch them with computePass.dispatchWorkgroups(n) instead of drawing. They read and write storage buffers. If you're doing anything involving particle systems, fluid simulation, or running a small ML model client-side, compute is where WebGPU really separates itself from everything that came before it.
In practice, most web devs don't write raw WebGPU directly for production 3D. Three.js r163+ has a WebGPU backend, wgpu-matrix handles math, and the Dawn/wgpu Rust bindings compile to WASM if you need that path. But understanding the raw API makes you a much better consumer of those libraries - you'll know why a dispose() call matters, why pipeline creation is expensive, and why you should batch your draw calls.
The visual design layer on top of your 3D canvas matters too. If you're combining WebGPU rendering with a React UI shell, the styling decisions you make around the canvas - backdrop blur, layered cards, color palette - have a big impact on the overall feel. Tools like the gradient generator can help you pull a cohesive color story from your shader palette into your UI. It's a small thing but it makes the difference between a demo and something that looks like a product.
FAQ
Yes, for Chrome and Firefox. Safari has had stable support since late 2025. You'd want a WebGL fallback for older mobile browsers, but desktop coverage is solid.
No - and honestly, WebGL habits can slow you down. WebGPU's mental model is different enough that starting fresh is easier than unlearning OpenGL state machine patterns.
Yes. Three.js added a WebGPU renderer in r163. You opt in via WebGPURenderer instead of WebGLRenderer. Not all features are parity yet but it's production-usable.
Nine times out of ten it's a missing COPY_DST usage flag on a buffer, a wrong format in the pipeline targets, or getCurrentTexture() called before context.configure(). Open the browser DevTools console - WebGPU validation errors are verbose and specific.
