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 anew 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
| Technique | When to use | Win |
|---|---|---|
| Offscreen cache | Static/expensive content reused each frame | Replace many ops with one drawImage |
| Layered canvases | Mixed static + dynamic content | Skip redrawing unchanged layers |
| Dirty regions | Few moving objects on a large canvas | Clear/clip only what changed |
| Pixel snapping | Crisp lines and sprites | Avoid anti-alias overhead and blur |
| Batch by state | Many objects sharing styles | Fewer state changes and draw calls |
OffscreenCanvas worker | CPU-heavy rendering | Keep 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
devicePixelRatioonce, 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/strokeStyleto cut redundant state changes. - Avoid
shadowBlur, largeglobalAlpharegions, and frequentgetImageDatain hot loops — they force expensive readbacks. - Move heavy rendering to a worker with
OffscreenCanvasso input and UI stay smooth.