The pixel backing store: buffer size vs display size
The canvas from the last lesson is "just a bitmap" for a precise reason — it owns a real pixel buffer whose dimensions come from the width and height attributes, entirely separate from the CSS size that decides how big that buffer is stretched on screen, and the gap between the two (compounded by device pixel ratio) is the single most common source of blurry canvas art.
The pixel backing store: buffer size vs display size
The immediate-mode lesson established that the canvas has no memory of what you draw — every command paints pixels into a bitmap and forgets. This lesson takes apart that bitmap itself: how big it actually is, why that size is not the same thing as how big the canvas looks on your screen, and why getting the two out of sync is the number one reason canvas drawings come out blurry.
The backing store is a real array of pixels
When you write <canvas width="400" height="300">, the browser allocates an actual pixel buffer — sometimes called the drawing buffer or backing store — of exactly 400 × 300 pixels. This is not a metaphor. It's a real block of memory, one slot per pixel, each holding a color (and alpha) value. Every fillRect, arc, and drawImage call from the last lesson writes directly into this array. Nothing else exists behind the canvas — no vector description, no shape list, just this grid of pixel values sitting there waiting to be composited onto the page.
The size of that array is controlled by exactly one thing: the canvas element's width and height attributes (or, equivalently, the canvas.width / canvas.height properties in JavaScript — they're the same underlying number). If you never set them, they default to 300 × 150 — a fact that trips people up constantly, because it means an unstyled canvas is quietly rendering into a 300×150 buffer no matter how the surrounding page lays it out.
Backing-store size vs display size: two different numbers
Here is the distinction the rest of this lesson is built on, and it's easy to miss because both numbers look like sizing information:
- Drawing-buffer size —
canvas.widthandcanvas.height. This is how many actual pixels exist in the backing store. It's set via the HTML attributes or the JS properties, and it's what every drawing command's coordinate space is measured in. - Display size — the CSS
widthandheight(whether set inline, via a stylesheet, or left to default to the buffer size). This is how large the canvas element is drawn on the page, in CSS pixels.
These are independent. Nothing forces them to match, and the browser will not complain if they don't. If they do differ, the browser takes the fixed-size bitmap from the backing store and scales it — stretches or shrinks it with image scaling — to fill whatever box the CSS display size describes. That's the same operation as taking a small JPEG and stretching it to fill a bigger <img> box: the pixel data doesn't change, only how large each pixel is drawn.
Device pixel ratio: the second multiplier
Even when the drawing-buffer size and the CSS display size agree in CSS pixel terms, there's a second mismatch waiting on hi-DPI (retina) screens. window.devicePixelRatio reports how many actual device pixels the screen packs into each CSS pixel — commonly 2 on Retina displays, sometimes 3 on high-density phones. A CSS pixel is not a screen pixel there; it's a 2×2 or 3×3 block of them.
If your backing store has exactly as many pixels as the CSS size in CSS-pixel units — say a 400×300 buffer for a 400×300px display box — then on a devicePixelRatio: 2 screen, the browser is once again upscaling: it has to stretch a 400×300 bitmap across an 800×600 grid of physical pixels to fill that box, and you get the same softness as the mismatch above, just for a subtler reason. The buffer was "the right size" in CSS terms and still too small in actual screen pixels.
The fix is to make the backing store match the device pixel count, not the CSS pixel count, and then compensate in your drawing coordinates so you can keep thinking in CSS pixels:
const cssWidth = 400;
const cssHeight = 300;
const dpr = window.devicePixelRatio || 1;
// Size the element on the page in CSS pixels...
canvas.style.width = cssWidth + "px";
canvas.style.height = cssHeight + "px";
// ...but size the backing store in real device pixels.
canvas.width = cssWidth * dpr;
canvas.height = cssHeight * dpr;
// Scale the drawing coordinate system to match, so ctx calls
// can still be written in CSS-pixel units.
ctx.scale(dpr, dpr);
ctx.fillRect(10, 10, 100, 50); // draws a 100x50 CSS-pixel rect,
// backed by real, un-stretched device pixelsctx.scale(dpr, dpr) multiplies every coordinate you pass to a drawing command by dpr before it's rasterized — so fillRect(10, 10, 100, 50) actually paints into an 800×600-pixel-scale buffer at (20, 20, 200, 100) when dpr is 2, but you never have to do that arithmetic yourself. This is the standard pattern: buffer in device pixels, coordinates in CSS pixels, scale() bridges the two. (What scale() and the rest of the transform actually do to every coordinate you pass is its own lesson — see the transform matrix.)
The coordinate system this buffer defines
Every coordinate you pass to a drawing command — fillRect(x, y, w, h), arc(x, y, r, ...) — is measured in this coordinate space: origin at the top-left corner of the backing store, x increasing rightward, y increasing downward (the opposite of the math convention you may expect), and units that are backing-store pixels before any transform is applied. Once you call ctx.scale(), ctx.translate(), or ctx.rotate(), that mapping from "coordinates you write" to "pixels actually touched" gets reshaped by the current transform matrix — which is exactly what ctx.scale(dpr, dpr) above was doing, and exactly what lesson 5 covers in full.
Why a 1px line looks 2px wide
Here's a concrete symptom of "pixels are discrete cells, not points," independent of any DPI issue. Draw what looks like it should be a crisp 1-pixel-wide vertical line:
ctx.beginPath();
ctx.moveTo(50, 0);
ctx.lineTo(50, 100);
ctx.lineWidth = 1;
ctx.stroke();This renders as a blurry 2-pixel-wide line, not a crisp 1-pixel one. The reason is that x = 50 names the mathematical boundary between pixel column 49 and pixel column 50 — it's a line, not a pixel. A 1-unit-wide stroke centered on that boundary covers half of pixel column 49 and half of pixel column 50, so the renderer anti-aliases both columns at 50% opacity, producing two faint gray columns instead of one solid one.
The standard fix is the +0.5 offset trick: stroke at x = 50.5 instead of x = 50. That places the line's center on the middle of pixel column 50, so the full 1-unit width lands entirely inside that one column with no straddling, and you get one crisp fully-opaque line instead of two blurry half-opacity ones. The same trick applies to horizontal 1px lines using a +0.5 on y.
Clearing resets pixels, not state
ctx.clearRect(x, y, w, h) writes transparent black (rgba(0,0,0,0)) into every pixel in that rectangle of the backing store. It only touches pixel contents — it does not reset fillStyle, the transform, or any other context state, which is why the immediate-mode animation loop from the last lesson could call clearRect every frame without having to re-set its style every time too.
The gotcha: setting canvas.width resets everything
There's a sharp edge here worth calling out explicitly: assigning to canvas.width (or canvas.height) — even setting it to the same value it already has — doesn't just resize the backing store. It allocates a brand-new backing store, which means the old one is discarded (so the canvas is implicitly cleared) and the entire context state — fillStyle, the current transform, the clip region, everything covered in the next lesson — resets to its defaults. Code that resizes a canvas on window resize has to re-apply every style and transform it cares about afterward, in the right order, or it'll silently draw with defaults instead of whatever it had configured before.
canvas.width = canvas.width; // looks like a no-op — it isn't:
// clears the bitmap AND resets all context stateWhere this goes next
The backing store is the surface; what actually gets painted onto it — colors, line styles, transforms, clip regions — all lives as state sitting in the context object, state that every immediate-mode command from lesson 1 reads implicitly instead of taking as an argument. The drawing state machine and the save/restore stack covers exactly what that state is, and how save()/restore() let you push and pop all of it at once.
Go deeper
- MDN — the `<canvas>` element — The authoritative reference for the width/height attributes, their 300x150 default, and how they differ from CSS sizing.
- MDN — Window.devicePixelRatio — The exact definition of the ratio this lesson's dpr-correct setup code depends on, plus notes on how it can change (e.g. dragging a window between monitors).
- web.dev — High DPI canvas — A focused walkthrough of the same backing-store-vs-display-size-vs-devicePixelRatio pattern this lesson derives, with additional worked examples.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- What is the drawing-buffer (backing store) size controlled by, and how is it different from the canvas's CSS display size?
- If a canvas's width/height attributes are left unset, what is the default backing-store size, and why does that surprise people who only set a CSS size?
- A canvas has width=300 in HTML but is styled with CSS to display at 600px wide. What does the browser do to the bitmap, and what does that look like visually?
- Why does a backing store sized to match the CSS pixel dimensions still look blurry on a devicePixelRatio: 2 screen, and what three lines of setup code fix it?
- Explain why a 1px-wide vertical line stroked at an integer x-coordinate renders as a blurry 2px line, and what the +0.5 offset trick does about it.
- What exactly does clearRect touch, and what does it leave alone?
- What happens, precisely, when you assign a value to canvas.width — even the value it already holds — and why does that matter for code that resizes a canvas on window resize?