Engineering Journal
Schema Editor
Schema Editor

Canvas Overlay Renders at Wrong Scale After the Second Resize: The ctx.scale() Accumulation Bug

2026-06-04

TLDR

A canvas overlay for SVG handles renders correctly on load, then renders at progressively wrong scale after window resizes. The cause: ctx.scale(dpr, dpr) is called inside ResizeObserver on every resize and multiplies the existing transform instead of replacing it. After three resizes the canvas renders at dpr^3 scale. Use ctx.setTransform(dpr, 0, 0, dpr, 0, 0) to reset before scaling.


Symptom

A canvas overlay renders selection handles at the correct DPR-scaled positions on load. After the first window resize, handles appear in slightly wrong positions. After the second resize, they are visibly offset. After the third, they are far from the selected elements.

The handles remain at the correct size (8px), but their positions drift further from the correct screen position with each resize event.

Why It Happens

The canvas element is resized in a ResizeObserver callback:

// wrong: ctx.scale() accumulates on every call
const ro = new ResizeObserver(() => {
    const r = container.getBoundingClientRect();
    const dpr = window.devicePixelRatio || 1;
    canvas.width = r.width * dpr;
    canvas.height = r.height * dpr;
    canvas.style.width = r.width + 'px';
    canvas.style.height = r.height + 'px';
    ctx.scale(dpr, dpr); // multiplies existing transform
});

ctx.scale(dpr, dpr) multiplies the canvas context's current transform matrix by the scale. On the first resize, the transform is the identity: the result is scale(dpr, dpr). On the second resize, the transform is already scale(dpr, dpr): the result is scale(dpr², dpr²). On the third: scale(dpr³, dpr³).

On a 2x DPR display after three resizes, coordinates are scaled by 8x. Handles drawn at (spt.x, spt.y) appear at (8 spt.x, 8 spt.y).

Setting canvas.width resets the canvas bitmap but does not reset the 2D context's transform matrix. The matrix persists across bitmap resets.

The Fix

// correct: setTransform replaces the matrix entirely
const ro = new ResizeObserver(() => {
    const r = container.getBoundingClientRect();
    const dpr = window.devicePixelRatio || 1;
    canvas.width = r.width * dpr;
    canvas.height = r.height * dpr;
    canvas.style.width = r.width + 'px';
    canvas.style.height = r.height + 'px';
    // setTransform(a, b, c, d, e, f) = [[a,c,e],[b,d,f],[0,0,1]]
    // This sets the DPR scale without accumulating
    ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    scheduleRender();
});

ctx.setTransform() replaces the entire current transform matrix with the provided values. The DPR scale is applied once, from the identity, on every resize. No accumulation.

How to Prevent It

Treat ctx.scale() as a relative operation (like +=) and ctx.setTransform() as an absolute operation (like =). In a ResizeObserver or any handler that may run multiple times, always use setTransform for DPR scaling. Use ctx.save() and ctx.restore() to scope relative transform operations within a single render pass.

The Generalizable Lesson

ctx.scale(), ctx.translate(), and ctx.rotate() are relative operations: they multiply the current transform. They are correct inside a render function that calls ctx.save() at the start and ctx.restore() at the end. They are wrong in setup or resize code that runs repeatedly, because each call stacks. Use ctx.setTransform() for any transform that should be set to an absolute value.

Read this post in the full Engineering Journal →