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.

DOM / SVGRetained elements. Accessible, styleable, laid out for you.tens–hundreds of shapes
Canvas 2DImmediate-mode pixels. You issue draw calls; nothing is remembered.thousands of shapes
WebGLGPU pipeline via shaders. 3D, or 2D at huge scale.100k+ triangles
WebGPUModern low-level API. Explicit pipelines plus general compute.render + GPGPU
The graphics ladder: each rung buys more performance and control at the cost of more code and lower-level thinking. Pick the highest rung that still hits your frame budget.

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.

interactiveImmediate mode: stamps become pixels, not objects
(0, 0)+x+yfillRect(250,90,…)
Canvas coordinate space: origin at top-left, y grows downward. Every draw call maps into this fixed grid.

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.

canvas pixelsp0p1p2.data (flat bytes)RGBARGBARGBAidx 0idx 4idx 84 bytes / pixel · 0–255 each
getImageData returns a flat Uint8ClampedArray: four bytes (R,G,B,A) per pixel, laid out row by row. Pixel (x,y) starts at index (y*width + x)*4.
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.

interactivePer-pixel filter with getImageData / putImageData

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.

clearRect()wipe the bitmapdraw framefrom your state + dtrequestAnimationFrameschedule nextbrowser repaint~60–120 Hz
The immediate-mode render loop: clear the bitmap, draw the current frame from your state, then schedule the next callback. rAF paces you to the display's refresh and pauses in background tabs.
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.

interactiveTime-based render loop with requestAnimationFrame

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.

vertexbufferspositions, uv…vertexshaderyou write thisrasterizerfixed-functionfragmentshaderyou write thispixelsonce per vertexonce per pixel
The GPU render pipeline (simplified). You supply vertex data plus two shader programs; the fixed-function rasterizer in the middle turns triangles into fragments. The vertex shader runs once per vertex, the fragment shader once per pixel — all in parallel.

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.

interactiveA real GPU-rendered gradient triangle (WebGL)

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”:

  1. Explicit pipelines. You build a GPURenderPipeline object 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.
  2. 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.

GPUDevicefrom requestAdapter/Devicerender pipeline→ pixels on canvascompute pipeline→ data in buffersqueue.submit()command buffer → GPU
WebGPU adds a compute path alongside rendering. The same device drives both: a render pipeline draws to the canvas, a compute pipeline crunches data in buffers and hands results back — GPU work with no pixels involved.

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.

main thread<canvas> on the pagehandles clicks, scroll,stays responsiveworker threadOffscreenCanvasrender loop + heavydraw calls live heretransfer
transferControlToOffscreen hands the canvas's rendering to a worker thread. The main thread only relays events; the worker owns the render loop, so heavy drawing never blocks scrolling or clicks.

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:

Handful of shapes, need a11y / CSS / hit-testing? → DOM or SVG.
Thousands of things redrawn every frame, mostly 2D? → Canvas 2D.
3D, or 2D at a scale Canvas can’t keep up with? → WebGL (via Three.js / PixiJS).
Need GPU compute, or cutting-edge render perf? → WebGPU, with a fallback.
Rendering janks the page? → move any of the above into a worker via OffscreenCanvas.
A rough decision path. Most projects settle on DOM/SVG or Canvas 2D; climb to the GPU only when measurements — not guesses — say you must.

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, y grows down. Great for thousands of frequently-changing shapes; reach for getImageData/putImageData for 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 regl in 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.