Skip to content

Canvas 2D and OffscreenCanvas

advanced17 min read

When the DOM Isn't Enough

The DOM is great for documents, layouts, and interactive UI. But try drawing a real-time chart with 10,000 data points, or applying a blur filter to an image pixel by pixel, or rendering a 2D game at 60fps. The DOM becomes a bottleneck — each element is a full object in memory with styles, layout, events, and accessibility metadata.

Canvas gives you a raw pixel buffer. You draw directly — shapes, text, images, pixels — with no DOM overhead. One element, infinite visual complexity.

Mental Model

Canvas is a digital whiteboard. The DOM is like a bulletin board where you pin individual notes (elements) that you can rearrange. Canvas is a blank surface where you paint with code. Once something is painted, it's just pixels — no "objects" to click or inspect. If you want interactivity, you track coordinates yourself. The tradeoff: unlimited visual freedom, but you manage everything.

Canvas 2D Basics

<canvas id="myCanvas" width="800" height="600"></canvas>
const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('2d');

The ctx (CanvasRenderingContext2D) is your drawing API. Everything goes through it.

Canvas size vs CSS size

The width and height attributes set the canvas resolution (in pixels). CSS width/height stretches the canvas visually. If they don't match, your content looks blurry. For crisp rendering on high-DPI screens, scale both: canvas.width = 800 * devicePixelRatio; canvas.height = 600 * devicePixelRatio; ctx.scale(devicePixelRatio, devicePixelRatio);

Drawing Shapes

ctx.fillStyle = 'var(--color-accent, #3b82f6)';
ctx.fillRect(10, 10, 200, 100);

ctx.strokeStyle = 'var(--color-danger, #ef4444)';
ctx.lineWidth = 2;
ctx.strokeRect(10, 10, 200, 100);

ctx.beginPath();
ctx.arc(300, 200, 50, 0, Math.PI * 2);
ctx.fill();

ctx.beginPath();
ctx.moveTo(400, 100);
ctx.lineTo(500, 200);
ctx.lineTo(400, 200);
ctx.closePath();
ctx.stroke();

Drawing Text

ctx.font = '24px system-ui, sans-serif';
ctx.fillStyle = '#000';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('Hello Canvas', canvas.width / 2, canvas.height / 2);

const metrics = ctx.measureText('Hello Canvas');
console.log('Width:', metrics.width);

Drawing Images

const img = new Image();
img.src = '/photo.jpg';
img.onload = () => {
  ctx.drawImage(img, 0, 0);
  ctx.drawImage(img, 0, 0, 200, 150);
  ctx.drawImage(img, 50, 50, 100, 100, 0, 0, 200, 200);
};

The three-argument form draws at position. The five-argument form scales. The nine-argument form crops a source rectangle and draws it to a destination rectangle — essential for sprite sheets.

Quiz
You set a canvas element to 400x300 via HTML attributes, but apply CSS width: 800px and height: 600px. What happens?

Pixel Manipulation

Canvas gives you direct access to the pixel buffer:

const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const pixels = imageData.data;

for (let i = 0; i < pixels.length; i += 4) {
  const r = pixels[i];
  const g = pixels[i + 1];
  const b = pixels[i + 2];

  const gray = 0.299 * r + 0.587 * g + 0.114 * b;
  pixels[i] = gray;
  pixels[i + 1] = gray;
  pixels[i + 2] = gray;
}

ctx.putImageData(imageData, 0, 0);

imageData.data is a Uint8ClampedArray — each pixel is 4 bytes (R, G, B, A). The clamped part means values are automatically clamped to 0-255.

Image Processing Pipeline

For real image processing, process in a worker using OffscreenCanvas or transfer the ImageData buffer:

const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);

worker.postMessage(
  { type: 'blur', pixels: imageData.data.buffer, width: canvas.width, height: canvas.height },
  [imageData.data.buffer]
);

The ArrayBuffer is transferred (zero-copy) to the worker, which processes it and transfers it back.

Transformations

Canvas supports an affine transformation matrix:

ctx.save();

ctx.translate(200, 200);
ctx.rotate(Math.PI / 4);
ctx.scale(2, 2);

ctx.fillRect(-25, -25, 50, 50);

ctx.restore();

save() and restore() push/pop the entire canvas state (transform, styles, clip region) on a stack. Always pair them — forgetting restore() is the source of countless canvas bugs.

Compositing

ctx.globalCompositeOperation = 'multiply';
ctx.globalAlpha = 0.5;

Composite operations control how new drawing blends with existing pixels. source-over (default) draws on top. multiply creates photographic blend effects. destination-out erases. There are 26 composite modes total.

Quiz
What does ctx.save() save, and what happens if you forget to call ctx.restore()?

OffscreenCanvas: Rendering in Workers

OffscreenCanvas lets you use the Canvas API inside a Web Worker — no DOM required. This is transformative for performance: heavy rendering (charts, image processing, games) moves off the main thread entirely.

Transfer Control to a Worker

const canvas = document.getElementById('gameCanvas');
const offscreen = canvas.transferControlToOffscreen();

const worker = new Worker('/render-worker.js');
worker.postMessage({ canvas: offscreen }, [offscreen]);

In the worker:

let ctx;

self.onmessage = (event) => {
  const canvas = event.data.canvas;
  ctx = canvas.getContext('2d');

  requestAnimationFrame(draw);
};

function draw() {
  ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);

  ctx.fillStyle = '#3b82f6';
  ctx.fillRect(Math.random() * 800, Math.random() * 600, 50, 50);

  requestAnimationFrame(draw);
}

The worker now owns the canvas. requestAnimationFrame works inside workers when using OffscreenCanvas. The main thread stays free for event handling and DOM updates.

Standalone OffscreenCanvas (No DOM)

You can also create an OffscreenCanvas without any visible canvas element — useful for generating images in a worker:

const offscreen = new OffscreenCanvas(800, 600);
const ctx = offscreen.getContext('2d');

ctx.fillStyle = '#000';
ctx.font = '48px serif';
ctx.fillText('Generated in a worker', 50, 100);

const blob = await offscreen.convertToBlob({ type: 'image/png' });

self.postMessage({ imageBlob: blob });
Quiz
Why would you use OffscreenCanvas in a worker instead of drawing on the main thread?

Performance Patterns

Layered Canvases

Instead of one canvas, use multiple stacked canvases:

<div style="position: relative">
  <canvas id="background" style="position: absolute"></canvas>
  <canvas id="sprites" style="position: absolute"></canvas>
  <canvas id="ui" style="position: absolute"></canvas>
</div>

The background redraws rarely. Sprites redraw every frame. UI redraws on interaction. By separating layers, you avoid redrawing static content every frame.

requestAnimationFrame Loop

let lastTime = 0;

function gameLoop(timestamp) {
  const delta = timestamp - lastTime;
  lastTime = timestamp;

  update(delta);
  render();

  requestAnimationFrame(gameLoop);
}

requestAnimationFrame(gameLoop);

Never use setInterval for animation. requestAnimationFrame syncs with the display refresh rate, pauses in background tabs, and gives you a timestamp for frame-independent animation.

Avoid Layout Thrashing

Reading canvas properties after drawing can force a layout recalculation. Batch your reads before your draws.

Pre-render to Off-Screen Buffers

For complex static elements, render once to an off-screen canvas and drawImage it every frame:

const spriteBuffer = document.createElement('canvas');
spriteBuffer.width = 100;
spriteBuffer.height = 100;
const spriteCtx = spriteBuffer.getContext('2d');

drawComplexSprite(spriteCtx);

function render() {
  ctx.drawImage(spriteBuffer, x, y);
}

drawImage with a canvas source is very fast — the browser copies pixel data directly, no re-rasterization.

Real-World Use Cases

  • Charts and data visualization: D3.js uses canvas for large datasets. Recharts/Chart.js render to canvas for performance.
  • Image editors: Crop, resize, filter, annotate. Pixel-level control is essential.
  • 2D games: Sprite rendering, tile maps, particle systems. Canvas is the standard for browser games.
  • PDF rendering: pdf.js renders PDF pages to canvas.
  • Signature capture: Touch input mapped to canvas paths.
What developers doWhat they should do
Not accounting for devicePixelRatio, resulting in blurry canvas on Retina displays
A canvas with width=400 on a 2x display renders 400 pixels into 800 CSS pixels, causing blur. Set canvas.width = 400 * devicePixelRatio, then ctx.scale(devicePixelRatio, devicePixelRatio) so your drawing coordinates stay the same.
Scale canvas resolution by devicePixelRatio and apply ctx.scale to match
Forgetting to call beginPath() before drawing a new shape
Without beginPath(), new moveTo/lineTo calls add to the existing path. When you stroke or fill, you draw ALL accumulated paths — including ones from previous frames. This causes ghost shapes and progressively slower rendering.
Call beginPath() before each new path to reset the sub-path list
Using setInterval for canvas animation
setInterval is not synchronized with the display refresh rate, causing frame drops and tearing. requestAnimationFrame fires at the optimal time for smooth rendering, automatically pauses in background tabs, and provides a high-resolution timestamp for frame-independent animation.
Use requestAnimationFrame for smooth, vsync-aligned rendering
Key Rules
  1. 1Canvas is a pixel buffer — draw shapes, text, and images directly with zero DOM overhead
  2. 2OffscreenCanvas moves rendering to a worker, keeping the main thread free for interactions
  3. 3Use save/restore to manage canvas state — forgetting restore causes cascading transform bugs
  4. 4Scale canvas resolution by devicePixelRatio for crisp rendering on high-DPI screens
  5. 5Use layered canvases and off-screen buffers to avoid redrawing static content every frame