Skip to content
JavaScript js canvas 5 min read

Canvas Performance

The 2D canvas can render thousands of shapes per frame, but only if you avoid redoing work the browser already did. Most canvas slowdowns come from redrawing static content every frame, thrashing the rendering context with redundant state changes, or forcing the GPU to anti-alias geometry that lands between pixels. This page collects the techniques that matter most in practice: caching with offscreen canvases, layering, dirty-region redraws, batching, and moving heavy work off the main thread with OffscreenCanvas.

Cache static content on an offscreen canvas

If part of your scene never changes — a background grid, a parallax map, a complex shape drawn once — render it a single time into a separate canvas, then blit that canvas in with one drawImage call. Copying a pre-rendered bitmap is dramatically cheaper than re-running hundreds of path commands each frame.

function createGridLayer(width, height, step) {
  const layer = document.createElement("canvas");
  layer.width = width;
  layer.height = height;
  const lctx = layer.getContext("2d");

  lctx.strokeStyle = "#2a2a3a";
  lctx.beginPath();
  for (let x = 0; x <= width; x += step) {
    lctx.moveTo(x + 0.5, 0);
    lctx.lineTo(x + 0.5, height);
  }
  for (let y = 0; y <= height; y += step) {
    lctx.moveTo(0, y + 0.5);
    lctx.lineTo(width, y + 0.5);
  }
  lctx.stroke(); // all lines drawn in a single stroke() call
  return layer;
}

// In the render loop, the whole grid is one cheap copy:
// ctx.drawImage(gridLayer, 0, 0);

Tip: An offscreen canvas is just an extra <canvas> (or a new OffscreenCanvas(w, h)) that you never insert into the DOM. It costs memory but saves enormous amounts of draw time for anything redrawn repeatedly.

Layer your canvases

When some content updates every frame (sprites, the player) and other content rarely changes (HUD, background), stack multiple <canvas> elements with CSS and redraw only the layer that changed. The compositor merges them for free.

<!DOCTYPE html>
<html>
<head>
<style>
  .stage { position: relative; width: 400px; height: 240px; }
  .stage canvas { position: absolute; inset: 0; }
  #bg { background: #11131c; }
</style>
</head>
<body>
<div class="stage">
  <canvas id="bg" width="400" height="240"></canvas>
  <canvas id="fg" width="400" height="240"></canvas>
</div>
<script>
  const bg = document.getElementById("bg").getContext("2d");
  const fg = document.getElementById("fg").getContext("2d");

  // Static layer: drawn ONCE.
  bg.fillStyle = "#3b82f6";
  for (let i = 0; i < 40; i++) {
    bg.globalAlpha = 0.15;
    bg.beginPath();
    bg.arc(Math.random() * 400, Math.random() * 240, 30, 0, Math.PI * 2);
    bg.fill();
  }
  bg.globalAlpha = 1;

  // Dynamic layer: only this clears + redraws each frame.
  let x = 0;
  function loop() {
    fg.clearRect(0, 0, 400, 240);
    fg.fillStyle = "#f59e0b";
    fg.beginPath();
    fg.arc(x, 120, 18, 0, Math.PI * 2);
    fg.fill();
    x = (x + 2) % 400;
    requestAnimationFrame(loop);
  }
  loop();
</script>
</body>
</html>

Redraw only dirty regions

Clearing and repainting the entire canvas every frame is wasteful when only a small area changed. Track the bounding box of what moved, clear just that rectangle, and clip drawing to it. This is the single biggest win for scenes with a few moving objects on a large surface.

function redrawDirty(ctx, dirty, drawScene) {
  ctx.save();
  ctx.beginPath();
  ctx.rect(dirty.x, dirty.y, dirty.w, dirty.h);
  ctx.clip();                                  // restrict all output
  ctx.clearRect(dirty.x, dirty.y, dirty.w, dirty.h);
  drawScene(ctx);                              // only pixels in the clip change
  ctx.restore();
}

Avoid sub-pixel coordinates

Drawing a 1px line at an integer coordinate places it across two pixel rows, so the browser anti-aliases it into a blurry 2px smear — extra work and worse output. Snap stroke positions to half-pixels and image/fill positions to whole pixels.

ctx.drawImage(sprite, x | 0, y | 0);  // round to whole pixels for crisp blits
ctx.moveTo((px | 0) + 0.5, 0);        // half-pixel offset for crisp 1px strokes

Minimize state changes and batch draws

Every assignment to fillStyle, strokeStyle, font, shadowBlur, or each save()/restore() pair has a cost. Group operations by state so you set a property once and draw many things, rather than flipping it per object. Likewise, accumulate geometry into one path and issue a single fill()/stroke().

// Slow: a state flip and a draw call per particle.
for (const p of particles) {
  ctx.fillStyle = p.color;
  ctx.fillRect(p.x, p.y, 2, 2);
}

// Fast: bucket by color, one fillStyle + one path per color.
for (const [color, group] of byColor) {
  ctx.fillStyle = color;
  ctx.beginPath();
  for (const p of group) ctx.rect(p.x, p.y, 2, 2);
  ctx.fill();
}

OffscreenCanvas in a worker

OffscreenCanvas decouples rendering from the DOM, so you can hand a canvas to a Web Worker and render entirely off the main thread — keeping input and layout responsive even under heavy draw loads. Transfer control with transferControlToOffscreen().

// main.js
const canvas = document.querySelector("#scene");
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker("render.js", { type: "module" });
worker.postMessage({ canvas: offscreen }, [offscreen]); // transfer ownership
// render.js (worker)
self.onmessage = ({ data }) => {
  const ctx = data.canvas.getContext("2d");
  const tick = () => {
    ctx.clearRect(0, 0, data.canvas.width, data.canvas.height);
    ctx.fillStyle = "#10b981";
    ctx.fillRect((Date.now() / 10) % data.canvas.width, 40, 30, 30);
    requestAnimationFrame(tick); // rAF is available inside workers
  };
  tick();
};

Warning: After transferControlToOffscreen(), the main thread can no longer draw to that canvas — all rendering must happen in the worker that owns it.

Quick checklist

TechniqueWhen to useWin
Offscreen cacheStatic/expensive content reused each frameReplace many ops with one drawImage
Layered canvasesMixed static + dynamic contentSkip redrawing unchanged layers
Dirty regionsFew moving objects on a large canvasClear/clip only what changed
Pixel snappingCrisp lines and spritesAvoid anti-alias overhead and blur
Batch by stateMany objects sharing stylesFewer state changes and draw calls
OffscreenCanvas workerCPU-heavy renderingKeep the main thread responsive

Best Practices

  • Profile before optimizing — use the browser’s Performance panel to confirm where frame time actually goes.
  • Never redraw static content per frame; pre-render it once to an offscreen canvas or a dedicated layer.
  • Set the canvas backing store size for devicePixelRatio once, not the CSS size every frame.
  • Round blit coordinates to whole pixels and snap 1px strokes to half-pixels for sharp, cheap output.
  • Sort and batch draws by fillStyle/strokeStyle to cut redundant state changes.
  • Avoid shadowBlur, large globalAlpha regions, and frequent getImageData in hot loops — they force expensive readbacks.
  • Move heavy rendering to a worker with OffscreenCanvas so input and UI stay smooth.
Last updated June 1, 2026
Was this helpful?