Canvas Overlay Renders at Wrong Scale After the Second Resize: The ctx.scale() Accumulation Bug
TLDR
Callingctx.scale(dpr, dpr) inside a ResizeObserver callback to handle high-DPI (Retina) screens causes progressive canvas distortion after every window resize. Because ctx.scale() multiplies the current transformation matrix rather than replacing it, the canvas transform accumulates scale factors across resize events (e.g., $dpr^1 \rightarrow dpr^2 \rightarrow dpr^3$). Replacing ctx.scale() with ctx.setTransform(dpr, 0, 0, dpr, 0, 0) resets the transformation matrix before applying the DPI factor.
| Transformation Method | Call Type | Behavior Across Multiple Resizes | Canvas Output |
|---|---|---|---|
ctx.scale(dpr, dpr) | Relative Multiplication | Accumulates scale ($dpr^1, dpr^2, dpr^3$) | Severe visual distortion / offset |
ctx.setTransform(dpr, 0, ...) | Absolute Matrix Reset | Resets matrix then sets scale ($dpr$) | 100% Crisp & Accurate Rendering |
Problem statement: progressive canvas drift on window resizes
When adding high-DPI canvas overlay support for SVG handles, everything looks crisp on initial page load.
However, after the user resizes the browser window twice, selection handles start drifting away from element corners. After three resizes, handles render at 8x scale completely off-screen.
Setting canvas.width and canvas.height resets the internal bitmap buffer, but does not reset the 2D context's transformation matrix.
Technical failure mode: transform matrix accumulation
The bug stems from calling relative scaling methods inside event handlers that fire repeatedly:
// DEFECTIVE IMPLEMENTATION: ctx.scale() multiplies existing transform!
const ro = new ResizeObserver(() => {
const r = container.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = r.width * dpr; // Resets bitmap dimensions canvas.height = r.height * dpr; canvas.style.width = r.width + 'px'; canvas.style.height = r.height + 'px';
ctx.scale(dpr, dpr); // DEFECT! Multiplies current matrix: scale *= dpr });
On a 2x Retina display (dpr = 2):
- Initial Load: Matrix scale is $2.0$.
- Resize 1: Matrix scale becomes $2.0 \times 2.0 = 4.0$.
- Resize 2: Matrix scale becomes $4.0 \times 2.0 = 8.0$.
The fix: absolute matrix assignment with ctx.setTransform()
Replace relative scaling calls with ctx.setTransform(), which replaces the transformation matrix entirely:
// CORRECT IMPLEMENTATION: Absolute matrix assignment
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) replaces matrix: [[dpr, 0, 0], [0, dpr, 0], [0, 0, 1]] ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
scheduleRender(); });
Rule of thumb: Never call relative transform methods (ctx.scale(),ctx.translate(),ctx.rotate()) insideResizeObserveror window resize callbacks without resetting the matrix. Usectx.setTransform()for absolute matrix assignment.