A blurry canvas is almost always one of four mistakes, and each produces a different artefact. Naming the artefact identifies the mistake, which is faster than re-deriving the whole pipeline.

Problem / Scenario Context

A sparkline sits in a dashboard card that resizes with the layout. It is drawn once at startup and redrawn from a ResizeObserver. On a standard display it looks correct; on a laptop with a pixel ratio of 2 the line is soft and the axis labels have coloured fringes, and after dragging the card's pane the chart is drawn at half scale in the top-left corner.

Three symptoms, one function. The chart is soft because the backing store was never scaled for the display. It is half-scale because the context transform was applied once at startup and lost. The fringes are subpixel positioning on a grid that does not line up with device pixels.

Mechanics Explanation

Four things must happen, in this order, every time the container's size changes.

Measure the container's box — from the entry, not from a fresh getBoundingClientRect(), because the entry already carries the measurement the browser computed.

Resize the backing store by writing canvas.width and canvas.height in device pixels. This assignment clears the surface and resets every piece of context state, including the transform.

Re-apply the transform with ctx.setTransform(ratio, 0, 0, ratio, 0, 0), so drawing code can keep working in CSS units.

Redraw, because the surface is now blank.

Skipping step two gives blur. Skipping step three gives a half-scale drawing. Doing step four before steps two and three gives a chart that vanishes on the next resize. Doing step one with a DOM read instead of the entry gives a forced layout inside the callback.

Which Missing Step Produces Which ArtefactFour rows pairing a step of the resize sequence with the artefact seen when it is omitted. Omitting the backing-store resize gives an evenly soft image. Omitting the transform reapplication gives a drawing at half scale in the top-left corner. Redrawing before resizing gives a blank canvas after the next resize. Measuring from the DOM instead of the entry gives a forced layout rather than a visual artefact.Step omittedWhat you seecanvas.width = cssW * dpruniformly soft — a small bitmap stretched to fitctx.setTransform(dpr, …)sharp, but half size in the top-left cornerredraw AFTER the resizeblank canvas — the resize cleared what you drewread entry.contentRectlooks fine, costs a forced layout every callback

Comparison Table: Sizing Sources

Source Value Verdict
entry.contentRect.width CSS pixels, fractional correct input; multiply by the ratio
entry.devicePixelContentBoxSize[0].inlineSize device pixels, integer best, where supported
canvas.getBoundingClientRect() CSS pixels correct value, forced layout to obtain it
canvas.clientWidth CSS pixels, rounded forced layout, and loses the fraction
window.innerWidth viewport wrong element entirely

Minimal Reproducible Example

TypeScript
// Blurry: the backing store never changes
const ro = new ResizeObserver(([entry]) => {
  const { width, height } = entry.contentRect;
  draw(ctx, width, height);          // canvas.width is still the default 300
});
ro.observe(shell);

Production-Safe Solution

TypeScript
const shell = document.querySelector<HTMLElement>('.chart-shell')!;
const canvas = shell.querySelector('canvas')!;
const ctx = canvas.getContext('2d')!;

function backing(entry: ResizeObserverEntry): { w: number; h: number; r: number } {
  const dpr = window.devicePixelRatio || 1;
  const dp = entry.devicePixelContentBoxSize?.[0];
  if (dp) return { w: dp.inlineSize, h: dp.blockSize, r: dpr };
  const cb = entry.contentBoxSize?.[0];
  const cssW = cb ? cb.inlineSize : entry.contentRect.width;
  const cssH = cb ? cb.blockSize : entry.contentRect.height;
  return { w: Math.round(cssW * dpr), h: Math.round(cssH * dpr), r: dpr };
}

let queued = false;
let latest: ResizeObserverEntry | null = null;

const ro = new ResizeObserver((entries) => {
  latest = entries[entries.length - 1];
  if (queued) return;
  queued = true;
  requestAnimationFrame(() => {
    queued = false;
    const entry = latest!;
    const { w, h, r } = backing(entry);
    if (w === 0 || h === 0) return;                 // collapsed container — nothing to draw
    if (canvas.width !== w || canvas.height !== h) {
      canvas.width = w;                             // 2. clears the surface and the transform
      canvas.height = h;
      ctx.setTransform(r, 0, 0, r, 0, 0);           // 3. restore CSS units
    }
    draw(ctx, entry.contentRect.width, entry.contentRect.height);   // 4. always redraw
  });
});

try { ro.observe(shell, { box: 'device-pixel-content-box' }); }
catch { ro.observe(shell); }

Two guards earn their place. The zero check prevents assigning a zero backing store when the container sits inside a collapsed accordion, which leaves an unusable context. The dimension comparison skips the clear-and-transform when only the unobserved axis changed, so a container that grows in height does not needlessly clear a chart whose width is unchanged — but the redraw still runs, because the drawing may depend on height.

A devicePixelRatio change also needs handling, because dragging a window between monitors does not resize anything:

TypeScript
let dprWatcher: MediaQueryList | null = null;
function watchRatio(): void {
  dprWatcher?.removeEventListener('change', onRatioChange);
  dprWatcher = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
  dprWatcher.addEventListener('change', onRatioChange, { once: true });
}
function onRatioChange(): void {
  canvas.width = 0;            // force the comparison above to see a difference
  watchRatio();
  shell.dispatchEvent(new Event('force-redraw'));
}
watchRatio();

Measure, Resize, Transform, DrawFour steps in sequence with the state after each. Measuring reads the entry and forces no layout. Resizing the backing store clears the surface and resets the transform. Reapplying the transform restores CSS drawing units. Drawing repopulates the now-blank surface. An arrow returns from the last step to the first for the next resize.1. measurefrom the entry — free2. resize storesurface + transform lost3. setTransformCSS units restored4. drawsurface repopulatednext resize — all four again, in the same orderStep 2 is destructive, which is why 3 and 4 are not optionalReordering any pair of these produces one of the artefacts in the previous table.

Retaining the Previous Frame

There is one refinement worth knowing about, because the naive resize sequence has a visible weakness on a slow drag: between clearing the surface and finishing the redraw, the canvas is blank. For a cheap chart this is a single frame and invisible. For an expensive one — thousands of marks, or a scene that has to be recomputed — it reads as flicker.

The fix is to keep the old bitmap and blit it while the real drawing catches up. Before resizing, copy the current surface into an offscreen canvas; after resizing, draw that copy stretched into the new box, then perform the real redraw on top. The stretched copy is wrong in detail — it is exactly the blur this page is about — but it is only on screen for one frame, and a slightly soft chart for 16 milliseconds is far less noticeable than a white rectangle.

TypeScript
const scratch = document.createElement('canvas');
function resizeKeepingFrame(w: number, h: number, r: number): void {
  scratch.width = canvas.width; scratch.height = canvas.height;
  scratch.getContext('2d')!.drawImage(canvas, 0, 0);
  canvas.width = w; canvas.height = h;
  ctx.setTransform(1, 0, 0, 1, 0, 0);
  ctx.drawImage(scratch, 0, 0, scratch.width, scratch.height, 0, 0, w, h);
  ctx.setTransform(r, 0, 0, r, 0, 0);
}

Use it only where a measurement says the redraw is expensive. For most charts the extra copy costs more than the flicker it prevents, and the plain sequence is the better default.

The One-Line CheckA verification expression that divides the canvas backing-store width by its rendered CSS width. The result should equal the device pixel ratio exactly. Values of one indicate the backing store was never scaled, and values of the ratio squared indicate the scaling was applied twice.Paste into the console with the chart on screencanvas.width / canvas.getBoundingClientRect().width= devicePixelRatio → correct= 1 → never scaled, blurry= ratio² → scaled twiceAnd check ctx.getTransform().a — it must equal the ratio, not 1, after a resize has occurred.The two together identify every one of the four artefacts described above.

Verification Steps

  • Check the ratio in the console: canvas.width / canvas.getBoundingClientRect().width should equal devicePixelRatio.
  • Check the transform survived: ctx.getTransform().a should equal the ratio after a resize, not 1.
  • Zoom the browser to 150% and confirm the chart stays sharp — browser zoom changes devicePixelRatio.
  • Drag between monitors with different densities and confirm the chart re-sharpens.
  • Collapse the container and reopen it; the chart must return rather than staying blank.

Common Mistakes to Avoid

  • Setting canvas.style.width instead of canvas.width. The style property changes the display box; only the attribute changes the pixel grid.
  • Applying setTransform once at startup. The first resize discards it silently.
  • Calling getBoundingClientRect() inside the callback. The entry already has the measurement and asking again risks a forced layout.
  • Observing the canvas itself. Writing its width can change its own box, which is the loop-limit error by another route.

FAQ

Why is the chart sharp but drawn at half size?

The backing store was scaled but the context transform was not re-applied. Writing to canvas.width resets every piece of context state, including the transform, so a setTransform call made once at startup disappears on the first resize and all subsequent drawing lands at one over the pixel ratio.

Do I need to redraw if only the height changed?

Yes, unless the drawing genuinely ignores height. The backing-store assignment can be skipped when neither dimension changed, but the redraw should run whenever the entry arrives, because the drawing may lay out differently in the new box even at the same width.

Does browser zoom change devicePixelRatio?

Yes. Zooming to 150 per cent changes the ratio, and a canvas whose backing store was sized at the old ratio becomes soft. Watching a resolution media query and forcing a resize when it changes covers both zoom and moving a window between displays.

What happens if the container collapses to zero?

Assigning a zero width or height to the canvas is legal and produces a surface that cannot be drawn to. Guard the callback with an early return on zero, and rely on the observer firing again when the container reopens to restore the chart.


↑ Back to Responsive Canvas & Chart Resizing