Canvas 2D Engineering
Canvas is fast because the browser forgets everything you draw. Everything it used to remember for you is now your job.
The story
A ticketing company builds a seat-map editor for stadiums. The first version uses SVG: each of the 30,000 seats is a <circle> with its own hover and click handlers. It's easy to build. Hover highlights come from CSS, clicks come from the DOM, and screen readers can see each seat.
At stadium size it falls over: the editor takes 3 seconds to open, zooming stutters at around 10 frames per second, and dragging a section of 2,000 seats freezes the page.
The team rewrites the map on a <canvas>. Zoom and pan become smooth at 60 fps, and the editor opens in under half a second. Then the list of things that stopped working arrives:
- Everything is blurry on laptops and phones with high-density screens.
- Clicking a seat does nothing. The canvas is one element: the browser can only say "the canvas was clicked at (412, 230)".
- Hover highlights are gone, and so are tooltips.
- Screen readers see an empty image. The map is inaccessible.
- Redrawing 30,000 seats on every mouse move to show a hover highlight makes the CPU fan spin.
Every one of these is fixable, and this chapter covers how. But the story shows the trade you're making: canvas gives you speed by giving you all the work the DOM was doing for you.
Part 1: Retained mode vs immediate mode
With canvas you keep your own scene model: an array (or tree) of the objects you draw, with their positions, sizes and state. Every frame you draw from that model. Every click you look up in that model. The canvas is just the output.
That's the source of both the speed and the work. The browser doesn't track 30,000 objects with styles and layout boxes; it tracks one bitmap. But anything that needs to know what is at a pixel now goes through your code.
Part 2: Setting up a canvas properly
Sharp on high-density screens
A canvas has two sizes: its CSS size (how big it looks) and its bitmap size (how many pixels it has). On a screen with devicePixelRatio 2, a 400×300 CSS canvas with a 400×300 bitmap is stretched 2× and looks blurry. Match the bitmap to the physical pixels:
function setupCanvas(canvas) {
const dpr = window.devicePixelRatio || 1
const { width, height } = canvas.getBoundingClientRect()
canvas.width = Math.round(width * dpr)
canvas.height = Math.round(height * dpr)
const ctx = canvas.getContext('2d')
ctx.setTransform(dpr, 0, 0, dpr, 0, 0) // draw in CSS pixels from now on
return ctx
}
// Re-run on resize (ResizeObserver) and when dpr changes (moving between monitors).
Watch the memory: a canvas bitmap costs width × height × 4 bytes. A full-screen canvas on a 4K display is about 33 MB, per canvas. Three stacked layers is 100 MB. Some apps cap the ratio at 2.
Only draw when something changed
A static canvas doesn't need 60 redraws a second. Keep a dirty flag, and schedule at most one redraw per frame:
let dirty = false
function invalidate() {
if (!dirty) { dirty = true; requestAnimationFrame(render) }
}
function render() {
dirty = false
draw(scene)
}
// call invalidate() from pointer handlers, data updates, resize…
A continuous loop is only for things that actually animate. An idle canvas should cost nothing, which matters for laptop and phone batteries.
Part 3: Drawing cheaply
The 2D context is a state machine, and changing state (colour, line width, font, transform) costs more than drawing. The tips that matter most:
| Do | Why |
|---|---|
| Batch by style: draw all red seats, then all blue ones | Fewer fillStyle changes |
One path, many shapes: beginPath, many rect/arc, one fill | One fill call instead of thousands |
Pre-render complex shapes once to an offscreen canvas, then drawImage it | Copying a small bitmap is cheaper than re-drawing paths with gradients |
Reuse Path2D objects for shapes that don't change | Avoids rebuilding the path every frame |
Avoid shadowBlur and filter in anything drawn often | They're very expensive per draw |
Align 1 px lines to half-pixels (x + 0.5) | Crisp lines instead of 2 px blurry ones |
Cache measureText results | Text measurement is slow. Labels rarely change. |
Wait for fonts (await document.fonts.ready) before drawing text | Otherwise the first draw uses a fallback font and never updates |
The render-race simulation in Chapter 7 uses the "one path, many shapes" style for its Canvas 2D renderer.
Part 4: Layers — don't redraw what didn't change
Most canvas apps have parts that change at very different rates. Stack several canvases on top of each other with CSS, and redraw each one only when it changes:
In the story, the hover highlight was redrawing all 30,000 seats on every mouse move. On its own layer, a hover redraw clears and draws one seat outline. The content layer is untouched.
Dirty rectangles
Within a layer, you can redraw only the region that changed: clear that rectangle, clip() to it, and redraw only objects that intersect it. It's worth it when changes are small and local (one seat changes colour). When much of the view changes at once (zoom, pan), a full redraw is simpler and often faster.
Part 5: Pan, zoom, culling and level of detail
Keep your scene in world coordinates (metres, or seat-grid units) and convert to screen pixels with one transform:
// view = { scale, x, y }
ctx.setTransform(dpr * view.scale, 0, 0, dpr * view.scale, dpr * view.x, dpr * view.y)
// Zoom around the cursor, so the point under it stays put:
function zoomAt(view, sx, sy, factor) {
const wx = (sx - view.x) / view.scale, wy = (sy - view.y) / view.scale
view.scale *= factor
view.x = sx - wx * view.scale
view.y = sy - wy * view.scale
}
Two techniques keep zoomed-out views cheap:
- Culling: only draw objects whose bounding box intersects the visible world rectangle. When zoomed in on one section, that's a few hundred seats out of 30,000. With a spatial index (Part 6), finding them is fast.
- Level of detail (LOD): draw less detail when things are small. Zoomed out, a section is a filled polygon, not 2,000 circles. Seat numbers appear only when seats are big enough to read. Maps do the same with tiles: different detail at each zoom level.
Part 6: Hit testing — "what did the user click?"
The canvas only tells you the pixel. Turning it into an object is your job. Three standard approaches:
- Geometry + spatial index is the default. Convert the pointer to world coordinates, ask a spatial index for objects near that point, then do exact math on those few. A uniform grid (bucket objects by cell) is simple and great for evenly spread objects like seats. An R-tree (e.g. the
rbushlibrary) handles uneven sizes and densities. Both turn "check 30,000 seats" into "check about 5". isPointInPath(path, x, y)is exact for any shape you can describe as aPath2D, but it's a per-shape test, so pair it with an index when there are many.- Colour picking draws every object into a hidden canvas with a unique colour (its ID encoded as RGB), then reads one pixel with
getImageData. It works for any shape, but reading pixels back is a synchronous stall. Create that context with{ willReadFrequently: true }and update it only when the scene changes.
Hover is just hit testing on pointermove. Throttle it to once per frame, and only redraw the interaction layer when the hovered object actually changes.
Part 7: Accessibility — the canvas is a black box
To assistive technology, a canvas is one image with no content. You have to add a parallel accessible structure:
- Fallback content inside the
<canvas>element is exposed to screen readers. For simple charts, a summary and a data table work well. - For interactive canvases, keep a lightweight DOM layer of focusable elements for the important objects (sections, selected seats) with proper roles and labels, positioned over the canvas or kept visually hidden. Keyboard focus moves through this layer, and your canvas draws a matching focus ring.
- Announce changes with an
aria-liveregion ("Seat 14C selected, ₹4,500"). - Provide non-visual paths to the same task: a searchable list of available seats is often more usable than any map.
This is the main cost reported when products move to canvas. When Google announced in 2021 that Docs would switch from HTML to canvas-based rendering, coverage noted both the performance and consistency goals and that extensions relying on the DOM would break, along with concerns about the accessibility layer that the DOM had provided.
Part 8: When canvas is the right call
Google Sheets renders its grid with canvas, Google Maps has drawn map tiles with canvas and WebGL for years, and Google Docs moved document rendering to canvas in 2021. The common thread is huge numbers of precisely positioned, frequently redrawn items where the DOM's per-element cost and layout model get in the way.
| Choose canvas when | Stay with DOM/SVG when |
|---|---|
| Thousands to hundreds of thousands of shapes | Up to a few thousand shapes |
| Constant redraws: pan, zoom, animation, live data | Mostly static content |
| You need exact control over every pixel | You want CSS, text selection, links and forms |
| You're prepared to build hit testing and a11y | Accessibility and events matter most and budget is tight |
Beyond a few hundred thousand shapes, or with heavy animation, even canvas 2D runs out of CPU, and the next step is the GPU: Chapter 7.
Numbers to remember
| Number | Meaning |
|---|---|
| width × height × 4 bytes | Memory per canvas bitmap (×dpr² for high-density screens) |
| ~33 MB | One full-screen canvas on a 4K display |
| 1–2 | Sensible cap for devicePixelRatio on huge canvases |
| ~5 | Candidates a good spatial index leaves you to test exactly, out of tens of thousands |
| 1 per frame | Maximum redraws. Zero when nothing changed. |
The DevTools workflow
- Performance panel: record a pan or zoom. On a canvas app, the cost appears as script (your draw functions), not as Layout/Paint. Bottom-Up shows which draw function is heavy.
- Wrap draws in
performance.mark/measureso they show up as named spans on the Timings track. - Rendering → Frame rendering stats for live FPS while you interact.
- Memory panel: check canvas count and size. Detached canvases and unreleased bitmaps leak memory.
- Screen reader test (VoiceOver, NVDA) on the canvas page before shipping.
Cheat sheet
| Problem | Fix |
|---|---|
| Blurry canvas | Bitmap = CSS size × devicePixelRatio, then setTransform(dpr…) |
| Redrawing when nothing changed | Dirty flag + one requestAnimationFrame per change |
| Hover redraws everything | Separate interaction layer |
| Thousands of draw calls | Batch by style. One path, many shapes. Pre-rendered sprites. |
| Zoomed out = slow | Culling + level of detail |
| "Which object was clicked?" | World coords + spatial index (grid / R-tree), or colour picking |
| Complex shape hit testing | Path2D + isPointInPath |
| Text measuring is slow | Cache measureText. Wait for document.fonts.ready. |
| Screen readers see nothing | Fallback content, a focusable DOM layer, aria-live announcements |
| Main thread busy elsewhere | OffscreenCanvas in a worker (Chapter 3) |
| Still too slow at 100k+ shapes | WebGL (Chapter 7) |
Say it in the interview
"At 30,000 seats I'd render on canvas, with my own scene model in world coordinates and a pan/zoom transform. Three layers: background, seats, and an interaction layer, so hover only redraws one outline. Rendering is dirty-flag driven with culling and level of detail, so zoomed out we draw sections, not seats. Clicks map to world coordinates and query a uniform-grid spatial index. For accessibility there's a focusable DOM layer for sections and selected seats, aria-live announcements, and a searchable list as a non-visual way to pick seats."
Go deeper
- Off the Main Thread: OffscreenCanvas and rendering from a worker.
- Virtualization: culling is virtualization for 2D scenes.
- FE-06 · CSS and Rendering Performance: layers and compositing, the browser's own version of Part 4.
- Next: GPU Rendering and Choosing a Renderer.