HTML5 Canvas: Immediate-Mode Rendering Decisions That Affect Performance and Accessibility
Canvas gives you a bitmap and a clock—everything else is yours to build. This post covers DPR scaling, state discipline with save/restore, and the accessibility work you can't skip.
28 Nov 2025, 02:59 UTC

The Problem: Pixels Without a Scene Graph
When you switch from SVG or CSS animations to <canvas>, you lose the browser's retained-mode rendering. Every frame, you clear the bitmap and redraw everything. That freedom lets you push thousands of particles at 60 fps, but it also means you own the entire draw loop, coordinate scaling, and accessibility fallback. The decisions you make in the first 50 lines of setup—context acquisition, device-pixel-ratio handling, state discipline—determine whether the result stays smooth or turns into a blurry, CPU-hungry mess.
Immediate-Mode Means You Clear and Redraw
Unlike the DOM, <canvas> has no memory of what you drew last frame. A typical animation loop looks like this:
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
function resize() {
const dpr = window.devicePixelRatio || 1;
canvas.width = canvas.clientWidth * dpr;
canvas.height = canvas.clientHeight * dpr;
ctx.scale(dpr, dpr);
}
window.addEventListener('resize', resize);
resize();
function frame() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
// draw calls here
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
The resize function scales the backing store to match the device pixel ratio (DPR). Without it, a 300×150 CSS canvas on a 2× display stretches 300×150 pixels over 600×300 physical pixels, producing visible blur. The ctx.scale(dpr, dpr) call keeps your drawing coordinates in CSS pixels while the bitmap stays sharp.
State Stack: Save Before You Transform
Canvas rendering state—transform matrix, fillStyle, lineWidth, globalAlpha, clipping region—is global to the context. If you rotate 45° to draw a needle, everything after that rotates until you undo it. The stack-based save()/restore() pair isolates changes:
function drawNeedle(angle) {
ctx.save();
ctx.translate(centerX, centerY);
ctx.rotate(angle);
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(0, -radius);
ctx.stroke();
ctx.restore(); // matrix, styles, clip all reset
}
Each save() pushes a copy of the entire state; restore() pops it. Nesting works, but deep nesting in hot loops allocates objects and can trigger GC pauses. For high-frequency draws (particles, tile maps), prefer manual inverse transforms or a lightweight state object you apply in one pass.
Worked Example: DPR-Aware Animated Rectangle
The snippet below runs in any modern browser. Save it as canvas-demo.html and open it directly—no server required.
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Canvas DPR Demo</title>
<style>
canvas { border: 1px solid #444; background: #111; }
body { font-family: system-ui; padding: 1rem; }
</style>
</head>
<body>
<canvas id="demo" width="600" height="300">
Fallback: animated rectangle moving horizontally.
</canvas>
<script>
const canvas = document.getElementById('demo');
const ctx = canvas.getContext('2d');
let x = 0, dir = 1;
function resize() {
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0); // replaces scale+translate
}
window.addEventListener('resize', resize);
resize();
function frame() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = '#4af';
ctx.fillRect(x, 50, 80, 40);
x += 2 * dir;
if (x > canvas.clientWidth - 80 || x < 0) dir *= -1;
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
</script>
</body>
</html>
Key points in this example:
ctx.setTransform(dpr,0,0,dpr,0,0)resets the matrix cleanly each resize, avoiding accumulated floating-point drift.- Drawing coordinates (
x, 50, 80, 40) stay in CSS pixels; the bitmap isdprtimes larger. - The
<canvas>element contains fallback text for screen readers and environments where scripting is disabled.
Trade-Off: Accessibility Requires Extra Work
Canvas renders pixels, not semantics. A screen reader sees only the fallback content inside the element. For anything interactive—charts, games, editors—you must:
- Provide a text alternative that conveys the same information (e.g., a table mirroring a bar chart).
- Add
role="img" aria-label="..."oraria-describedbypointing to a hidden description. - Implement keyboard focus management yourself if the canvas receives input.
If the visual output is purely decorative, aria-hidden="true" on the canvas plus meaningful fallback text is sufficient. There is no built-in hit-testing; you must map pointer coordinates to your own object model.
Verification Checklist
Before shipping a Canvas feature, run through these manual checks:
- Open the page on a 1× and a 2× display (or toggle DPR in DevTools device toolbar). The drawing should stay crisp, not blur.
- Profile a 10-second recording in Chrome DevTools Performance panel. Look for
clearRectand draw calls under 1 ms each; total frame time < 16.6 ms. - Navigate with a screen reader (NVDA, VoiceOver). Verify the fallback content or ARIA label is announced.
- Resize the window rapidly; no console errors, no layout shift, canvas re-scales smoothly.
Closing: Start Small, Measure Early
Canvas gives you a bitmap and a clock. Everything else—scene graph, hit testing, accessibility tree—is yours to build or skip. The minimal setup above (DPR scaling, requestAnimationFrame, state isolation) covers 80% of production needs. Add a state manager or a library like Fabric.js only when profiling shows the raw API is the bottleneck. Verify on real devices, not just desktop Chrome, and keep the fallback content honest about what the user is missing.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.