Under the Hood
Canvas

Canvas performance and the render loop

Because immediate mode means every frame is a full clear-and-redraw rather than an edit, canvas performance is really the art of finishing that redraw inside the frame budget — cutting overdraw, avoiding readback stalls, and reusing work through layering and offscreen caching instead of repainting everything from scratch every time.

Canvas performance and the render loop

Lesson 1 established the fact this whole module has built on: the canvas has no memory, so animating anything means clearing the surface and redrawing the entire scene from your own state, every frame. That's not a limitation you work around — it's the model. Canvas performance, then, isn't a separate topic bolted onto the API; it's the direct consequence of that model meeting a hard deadline. This lesson is about finishing that clear-and-redraw inside the budget, reliably, frame after frame.

The render loop

A canvas animation's outer shape is always the same: requestAnimationFrame schedules a callback right before the next paint, the callback clears the canvas, advances your own state, and redraws the whole scene from that state.

function frame(now) {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  updateState(now);   // your own variables — the canvas remembers nothing
  drawScene(ctx, state);
  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

That loop lives inside the exact frame budget the frame and the 16ms budget lesson lays out — roughly 16.6ms at 60Hz, most of it competing with the browser's own style/layout/paint/composite work on the same main thread. Everything below is about keeping drawScene cheap enough that it reliably fits.

The cost model

Three separate things eat your budget, and they don't trade off against each other — you have to watch all three.

Fill rate is the total number of pixels the GPU or CPU actually has to paint this frame. A single fillRect covering the whole canvas costs more than one covering a tenth of it; a shadowBlur costs more than a flat fill because it paints a blurred copy underneath the shape; a hundred overlapping semi-transparent shapes cost more than a hundred non-overlapping opaque ones, because every overlapping layer of pixels gets painted and blended again even though only the top layer ends up visible — a waste called overdraw.

Per-call overhead and state changes matter because every context property write and every draw call has a small fixed cost, and thrashing state is a common way to pay it needlessly. Switching fillStyle back and forth between two colors while interleaving shapes of each color is more expensive than sorting your draws so all the shapes sharing a fillStyle happen together:

// Worse: state ping-pongs on every iteration
for (const shape of shapes) {
  ctx.fillStyle = shape.color;
  ctx.fillRect(shape.x, shape.y, shape.w, shape.h);
}

// Better: group by shared state, set it once per group
for (const color of uniqueColors) {
  ctx.fillStyle = color;
  for (const shape of shapesByColor[color]) {
    ctx.fillRect(shape.x, shape.y, shape.w, shape.h);
  }
}

Readback stalls are the single most expensive thing you can do per frame: calling getImageData (lesson 7) forces pending GPU draws to finish and copies pixels back to CPU memory before your code can proceed. Doing that once at setup is fine; doing it inside the render loop, every frame, is one of the most common ways a canvas animation blows its budget without the author noticing why.

Techniques that cut real work

Dirty rectangles. Instead of clearRect-ing and redrawing the entire canvas every frame, track which regions actually changed since the last frame and only clear and redraw those. This reintroduces a sliver of retained-mode thinking — you keep a small memory of "what changed" — specifically to avoid paying full-canvas fill rate when, say, only a 40×40 icon in the corner is animating while the rest of a large canvas is static.

Layering with multiple canvases. Stack two or more <canvas> elements in the same position with CSS (position: absolute, matching dimensions) and split the scene by how often each part changes: a background canvas holding rarely-changing content (a chart's gridlines, a game's terrain) that you draw once and leave alone, and a foreground canvas redrawn every frame for the parts that actually animate. The static layer never re-pays its fill rate; only the moving layer does.

// bgCanvas: drawn once, never touched again this session
drawStaticBackground(bgCtx);

// fgCanvas: cleared and redrawn every frame, stacked on top via CSS
function frame() {
  fgCtx.clearRect(0, 0, fgCanvas.width, fgCanvas.height);
  drawMovingSprites(fgCtx, state);
  requestAnimationFrame(frame);
}

Offscreen caching. If a shape is expensive to rasterize (a complex path, a gradient, blurred shadows, a rendered text label) but gets drawn many times per frame or across many frames unchanged, render it once to an off-screen canvas and drawImage that cached bitmap everywhere you'd otherwise re-run the expensive draw. This is caching rasterization itself: drawImage blitting pixels is far cheaper than re-running the path/fill/shadow pipeline that produced them.

const spriteCanvas = new OffscreenCanvas(64, 64);
drawExpensiveSprite(spriteCanvas.getContext('2d')); // paid once

function frame() {
  ctx.clearRect(0, 0, canvas.width, canvas.height);
  for (const p of particles) {
    ctx.drawImage(spriteCanvas, p.x, p.y); // cheap blit, paid every frame
  }
  requestAnimationFrame(frame);
}

Two smaller habits round these out: snapping coordinates to integers avoids the extra antialiasing work the canvas does when a shape's edge lands between pixels, and simply avoiding expensive per-frame operations — shadowBlur, gradients recomputed from scratch, per-frame getImageData — is often the single biggest win available, ahead of any clever technique.

OffscreenCanvas and workers: off the main thread entirely

Every technique so far still runs on the main thread, competing with your own input handlers and the browser's style/layout/paint work for the same 16.6ms. OffscreenCanvas sidesteps that: it's a canvas not attached to the DOM, which can be transferred into a Web Worker with canvas.transferControlToOffscreen() and then drawn to entirely from that worker's own thread.

// main thread
const offscreen = canvas.transferControlToOffscreen();
worker.postMessage({ canvas: offscreen }, [offscreen]);

// worker thread
self.onmessage = ({ data }) => {
  const ctx = data.canvas.getContext('2d');
  function frame() {
    ctx.clearRect(0, 0, data.canvas.width, data.canvas.height);
    drawHeavyScene(ctx);
    requestAnimationFrame(frame);
  }
  frame();
};

Canvas vs SVG/DOM vs WebGL/WebGPU

Everything in this module has been about one point on a spectrum of graphics technologies, and it's worth placing it now that you've seen the whole model:

  • Canvas 2D wins when you have a large or unbounded number of visual elements, want per-pixel control, and are willing to own state, hit-testing, and redraw logic yourself — particle systems, data visualizations with tens of thousands of points, image editors, games with many simultaneously-animating sprites.
  • SVG/DOM wins when the element count is manageable and you want retained-mode's built-in layout, accessibility, and event handling for free — a chart with a few dozen bars each needing a tooltip and a screen-reader label is usually happier as SVG than as canvas pixels you'd have to hand-roll hit-testing for.
  • WebGL/WebGPU wins when you need the GPU's real parallelism — 3D scenes, shader effects, or 2D scenes with far more elements than Canvas 2D's CPU-adjacent pipeline can push per frame. The WebGL state machine and how a triangle becomes pixels lessons cover that world's very different mechanics.

None of these is a strict upgrade over the others; each trades away something the others offer for free.

The module, end to end

Eight lessons, one thread running through all of them: the canvas is immediate mode, so it forgets everything the instant it's painted (lesson 1). What it doesn't forget is the pixel buffer itself — a real grid of device pixels with its own resolution story (lesson 2). Every draw call reads its appearance from sticky context state, managed with save/restore (lesson 3), and that state includes a path built in two phases — describe, then stroke or fill (lesson 4) — transformed through a matrix that reinterprets every coordinate before it's rasterized (lesson 5). New pixels don't just overwrite old ones; they combine according to a compositing rule you control (lesson 6). Text and images are two more ways pixels get into that buffer, on top of paths, plus one way to read them back out (lesson 7). And all of it has to happen inside a frame deadline, which is what this lesson has been about. Immediate mode is why the canvas forgets — and performance, in the end, is just what it costs to make it remember convincingly, sixty times a second.

Where this goes next

The canvas module ends here, but the mechanics don't stop being relevant — the same frame-budget thinking applies directly to the frame and the 16ms budget and the rest of the animation module, and the same "own your state, do the redraw yourself" shape reappears, at a lower level and with the GPU doing the heavy lifting, in WebGL's state machine and how a triangle becomes pixels.

Go deeper

  • web.dev — Improving canvas performance A practical checklist of the same overdraw, batching, and offscreen-caching techniques this lesson covers, from the browser team's own performance guidance.
  • MDN — OffscreenCanvas The full API for transferring a canvas to a worker, including feature detection and the synchronous BitmapRenderer alternative to requestAnimationFrame.
  • MDN — Optimizing canvas MDN's own optimization tutorial, covering dirty rectangles, layering, and avoiding expensive per-frame operations in more depth.

Check yourself

Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.

  1. Walk through the render loop's four steps and say which frame-budget lesson governs how much total time they have to fit into.
  2. Name the three things that eat frame budget in the cost model, and give one concrete cause of each.
  3. How does batching draws by shared fillStyle reduce cost, compared to interleaving colors shape by shape?
  4. What problem do dirty rectangles solve, and what small piece of 'memory' do they require you to keep that pure immediate mode otherwise avoids?
  5. Explain the layered-canvas technique: what goes on the background layer vs the foreground layer, and why does that split save work?
  6. What is offscreen caching actually caching, and why is drawImage-ing a cached bitmap cheaper than re-running the original draw calls?
  7. Why does moving heavy drawing into an OffscreenCanvas inside a worker help even if the total amount of drawing work is unchanged?