Canvas, WebGL & WebGPU
The DOM is fantastic at laying out a document. It is terrible at drawing ten thousand moving particles. Every element is a live object the browser must lay out, paint, and keep in sync — wonderful for a button, ruinous for a fireworks simulation.
When you need to draw rather than arrange, the web hands you a different model: a rectangle of pixels you paint directly, backed — when you ask for it — by the GPU. That model comes in layers, from a friendly 2D drawing API up to a low-level interface that talks almost straight to the graphics card. This article walks the whole stack and, just as importantly, tells you which rung to stand on.
Four ways to put pixels on screen
Before any code, get the map in your head. There are four broad tools, and they trade ease for raw throughput.
Higher on the ladder means more of the work happens on the GPU and less code runs per pixel in JavaScript — but you write more, and you think in terms the graphics card understands. Most interactive web graphics never need to climb past the second rung. Start there.
Canvas 2D: immediate-mode drawing
A <canvas> is just a fixed-size bitmap with a drawing API bolted on. You grab a context and issue commands; each one paints onto the bitmap right away and is then forgotten. This is the defining trait of Canvas 2D — it is immediate mode. There is no scene graph, no list of “shapes on screen,” no way to select a circle you drew and move it. There is only the pixel grid and the marks you have made on it.
<canvas id="board" width="320" height="180"></canvas>
const canvas = document.getElementById('board');
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#6d3bef';
ctx.fillRect(20, 20, 120, 60); // x, y, width, height
ctx.strokeStyle = '#0aa';
ctx.lineWidth = 4;
ctx.beginPath();
ctx.arc(220, 90, 40, 0, Math.PI * 2); // x, y, radius, start, end (radians)
ctx.stroke();
Two things trip up newcomers immediately. First, that width/height on the element are the bitmap dimensions, not CSS pixels — setting them in CSS stretches the same pixels and gives you a blurry mess. Second, the coordinate origin (0, 0) is the top-left, with y growing downward. That inverted y will bite you at least once.
Here is immediate mode in miniature. Click the canvas to stamp a circle wherever you point. Notice the marks simply accumulate — each one is now anonymous pixels, not an object you can select, move, or delete. The only way to remove one is to wipe everything and repaint.
Paths, text, and images
Beyond rectangles, everything shape-like is a path — a sequence you build up with beginPath(), moveTo(), lineTo(), arc(), bezierCurveTo(), and friends, then commit with fill() or stroke(). The path is a temporary description; once you fill it, only the pixels remain.
ctx.beginPath();
ctx.moveTo(40, 140);
ctx.lineTo(90, 40);
ctx.lineTo(140, 140);
ctx.closePath();
ctx.fillStyle = 'tomato';
ctx.fill();
ctx.font = '16px system-ui';
ctx.fillStyle = '#111';
ctx.fillText('a filled triangle', 40, 170);
// draw an existing image, video frame, or another canvas
ctx.drawImage(someImageEl, 200, 40, 120, 90);
Transforms stack on top. ctx.translate(), ctx.rotate(), and ctx.scale() mutate the current transformation matrix, so a common pattern is to ctx.save(), transform, draw, then ctx.restore() back to a clean slate. That save/restore discipline keeps one wobbly sprite from rotating the entire scene.
Reading and writing raw pixels
Because a canvas is a bitmap, you can reach in and touch individual pixels. getImageData(x, y, w, h) returns an ImageData whose .data is a Uint8ClampedArray — four bytes per pixel, in R, G, B, A order, row by row. You mutate that array and hand it back with putImageData.
const img = ctx.getImageData(0, 0, canvas.width, canvas.height);
const px = img.data; // Uint8ClampedArray, length = w*h*4
for (let i = 0; i < px.length; i += 4) {
const gray = 0.299 * px[i] + 0.587 * px[i + 1] + 0.114 * px[i + 2];
px[i] = px[i + 1] = px[i + 2] = gray; // desaturate, leave alpha
}
ctx.putImageData(img, 0, 0);
That is a full grayscale filter in a handful of lines. It is also a warning: this loop runs on the CPU, single-threaded, one pixel at a time. For a 4K image that is eight million iterations per frame. Fine for a one-off filter, a disaster for real-time video. When per-pixel math becomes the bottleneck, that is your cue to move up the ladder to the GPU.
Try it on a real bitmap. The demo paints a colored gradient with a few shapes, then lets you run that exact getImageData → mutate → putImageData loop. Edit the formula in the code — for example, keep only the red channel — and re-run to see the pixels change.
The render loop
Static drawings are the easy case. Animation means redrawing the whole canvas many times a second, and the browser gives you exactly one correct way to schedule that: requestAnimationFrame (rAF). You pass a callback; the browser calls it right before the next repaint, at the display’s refresh rate, and passes a high-resolution timestamp. Inside the callback you clear, draw the new frame, and ask for the next one.
let x = 0;
function frame(now) { // `now` is a DOMHighResTimeStamp in ms
ctx.clearRect(0, 0, canvas.width, canvas.height);
x = (x + 2) % canvas.width;
ctx.fillStyle = '#6d3bef';
ctx.fillRect(x, 70, 40, 40);
requestAnimationFrame(frame); // book the next frame
}
requestAnimationFrame(frame);
Why not setInterval(draw, 16)? Because rAF is synced to the display, so you never tear or render frames the screen will not show; it pauses entirely in background tabs, saving battery; and it adapts to 120 Hz screens for free. Use the timestamp delta between frames to make motion time-based (pixels = speed * dt) rather than frame-based, or your animation runs twice as fast on a 120 Hz monitor.
Here is that loop running for real: a ball whose velocity is measured in pixels per second, scaled by the frame delta so the speed slider changes how fast it crosses the canvas without touching the frame rate. Pause it to confirm the loop truly stops requesting frames.
When canvas beats DOM and SVG
Reach for Canvas 2D when you have many things that change every frame and you do not need each one to be a queryable, accessible element: particle systems, data plots with thousands of points, image editors, game sprites, audio visualizers. Stick with DOM or SVG when you have a modest number of shapes that need accessibility, CSS styling, hit-testing, or hand-off to assistive tech — a chart with 40 labelled, hoverable bars is happier as SVG. The dividing line is roughly “does the browser’s per-element bookkeeping become the bottleneck?” When it does, canvas wins by throwing that bookkeeping away.
WebGL: handing the work to the GPU
Canvas 2D runs your drawing logic on the CPU. WebGL is a different context on the same <canvas> element that instead drives the GPU directly. You do not call fillRect; you upload arrays of vertex data and two small programs — shaders — that the graphics card runs in massive parallel to turn those vertices into colored pixels.
const gl = canvas.getContext('webgl2'); // or 'webgl' for the older version
The mental model that matters is the pipeline. Your data flows through fixed stages, two of which you program yourself.
The two programmable stages are written in GLSL, a small C-like language that runs on the GPU. The vertex shader positions each point; the fragment shader decides the color of each pixel the triangle covers. A trivial pair looks like this:
// vertex shader — runs once per vertex, outputs a clip-space position
#version 300 es
in vec2 a_position;
void main() {
gl_Position = vec4(a_position, 0.0, 1.0);
}
// fragment shader — runs once per covered pixel, outputs a color
#version 300 es
precision mediump float;
out vec4 outColor;
void main() {
outColor = vec4(0.43, 0.23, 0.94, 1.0); // solid violet, RGBA 0–1
}
The reason this is fast is parallelism: the fragment shader is not a loop you run, it is a function the GPU executes for thousands of pixels simultaneously. That grayscale filter from earlier, rewritten as a fragment shader, runs the whole 4K frame in a fraction of a millisecond instead of chewing through eight million CPU iterations.
The cost is ceremony. Raw WebGL to draw one triangle is easily 80 lines: compile each shader, link them into a program, create buffers, describe the memory layout of your vertex attributes, bind everything, then finally gl.drawArrays. It is powerful and verbose in equal measure.
Below is the whole ceremony, running live and rendered by your GPU. Each corner carries its own color; the fixed-function rasterizer interpolates them across every fragment for free. Hit Randomize to feed new colors into the vertex buffer and redraw — that is the pipeline you built, reused.
WebGL 1 and WebGL 2 are both Baseline widely available — supported across Chrome, Edge, Firefox, and Safari for years, so you can ship them without a second thought. WebGL 2 (the 'webgl2' context) adds a stronger shader language and features like transform feedback; prefer it unless you must support genuinely ancient devices.
WebGPU: the modern successor
WebGL, for all its reach, is an API designed around graphics hardware of the early 2010s, itself modeled on OpenGL ES from the 2000s. Modern GPUs work differently, and modern native APIs (Vulkan, Metal, Direct3D 12) expose that difference. WebGPU is the web’s answer: a from-scratch API that maps cleanly onto those modern backends.
Two things make it more than “WebGL 2.0”:
- Explicit pipelines. You build a
GPURenderPipelineobject up front that bundles your shaders, vertex layout, and state. The driver validates it once; per-frame you just bind and draw. Less per-call overhead, more predictable performance. - General compute. WebGPU has compute shaders — programs that do arbitrary parallel math on the GPU with no rendering involved. This is the big one. It is how browser-side machine-learning libraries now run model inference on your GPU, how physics simulations scale, how you do heavy number-crunching the CPU would choke on.
Shaders are written in WGSL (WebGPU Shading Language), a new language designed alongside the API rather than borrowed from OpenGL. The setup is asynchronous from the first line, because acquiring the GPU is genuinely an async negotiation:
if (!navigator.gpu) throw new Error('WebGPU not available here');
const adapter = await navigator.gpu.requestAdapter(); // pick a GPU
const device = await adapter.requestDevice(); // logical handle
const context = canvas.getContext('webgpu');
context.configure({
device,
format: navigator.gpu.getPreferredCanvasFormat(), // e.g. 'bgra8unorm'
});
From there you create a shader module from WGSL source, build a pipeline, encode a render (or compute) pass into a GPUCommandEncoder, and submit the buffer to device.queue. The shape is more explicit than WebGL, but also more honest about what the hardware is doing.
Where WebGPU stands in mid-2026
Be precise about maturity, because “the future” and “ship it today” are different claims. As of the WebGPU spec dated 23 June 2026, it is a W3C Candidate Recommendation Draft — advanced and stable, but not yet a finished Recommendation. Implementation has caught up fast:
- Chrome / Edge — shipped by default since Chrome 113 (April 2023) on desktop; Android followed in Chrome 121.
- Safari — enabled by default in Safari 26 across macOS, iOS, iPadOS, and visionOS (2025).
- Firefox — on by default on Windows since Firefox 141 and on Apple-silicon macOS since 145; Linux and Android still in progress.
OffscreenCanvas: rendering off the main thread
All three contexts share one enemy: the main thread. Every rAF callback, every draw call, every pixel loop competes with your event handlers, layout, and everything else on the one thread that keeps the page responsive. A heavy render can jank scrolling and clicks even when the GPU is idle, because the JavaScript driving it is stuck behind other work.
OffscreenCanvas breaks that coupling. It is a canvas with no DOM element attached, which means it can live inside a Web Worker — a separate thread. You transfer control of an on-page <canvas> to a worker, and all the drawing happens there while the main thread stays free to handle input.
The handoff is one method, transferControlToOffscreen(), plus a postMessage that moves the object across the thread boundary:
// main.js
const canvas = document.getElementById('board');
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker('render-worker.js');
// the OffscreenCanvas is transferable — pass it in the transfer list
worker.postMessage({ canvas: offscreen }, [offscreen]);
// render-worker.js
self.onmessage = ({ data }) => {
const ctx = data.canvas.getContext('2d'); // or 'webgl2' / 'webgpu'
function frame(now) {
ctx.clearRect(0, 0, data.canvas.width, data.canvas.height);
// ...draw...
self.requestAnimationFrame(frame); // rAF exists in workers too
}
self.requestAnimationFrame(frame);
};
Once transferred, the main thread can no longer draw to that canvas — ownership has genuinely moved. You communicate with the worker by postMessage (send it pointer coordinates, resize events, new data) and it repaints without ever touching the main thread. The same works for WebGL and WebGPU contexts, not just 2D.
OffscreenCanvas and transferControlToOffscreen are Baseline widely available — Chrome since 69, Firefox since 105, Safari since 16.4 (March 2023) — so this pattern is safe to ship broadly today. The usual worker rules apply: no DOM inside the worker, and everything crosses the boundary by message.
Choosing a layer
Put the whole decision on one line: use the highest rung that clears your frame budget, and no higher. Concretely:
The trap is starting too high. Raw WebGL for a bar chart, or WebGPU for something Canvas 2D would render at 120 fps, buys you weeks of pipeline plumbing and a maintenance burden for performance you never needed. Measure first. The nice thing about this stack is that the rungs share a substrate — they all draw into a <canvas> — so moving up when the numbers demand it is a change of context, not a rewrite of your whole app.
Summary
- Canvas 2D is immediate-mode: you issue draw calls onto a fixed bitmap and they are forgotten. No scene graph, top-left origin,
ygrows down. Great for thousands of frequently-changing shapes; reach forgetImageData/putImageDatafor per-pixel work, but know it is CPU-bound and can taint or stall. - The render loop is
clear → draw → requestAnimationFrame. rAF paces you to the display, pauses in background tabs, and hands you a timestamp — drive motion by time delta, not frame count. - WebGL runs your work on the GPU through a fixed pipeline: vertex buffers → vertex shader → rasterizer → fragment shader → pixels, with the two shader stages written by you in GLSL. It is Baseline and powerful but verbose; use Three.js, PixiJS, or
reglin practice. - WebGPU is the modern low-level successor: explicit pipelines, WGSL shaders, and — the headline feature — general compute for GPU math beyond graphics. As of 23 June 2026 it is a W3C Candidate Recommendation Draft, shipping in Chrome, Edge, and Safari and partially in Firefox. Treat it as progressive enhancement with a fallback.
- OffscreenCanvas (Baseline) moves rendering off the main thread.
transferControlToOffscreen()hands a canvas to a worker so heavy drawing never blocks input; it works for 2D, WebGL, and WebGPU alike. - Choose the highest rung that clears your frame budget, and no higher. Measure before you climb.