Resize a WebGL (or WebGPU) canvas from a ResizeObserver observing device-pixel-content-box: set canvas.width/height to the exact device-pixel size (capped to a pixel budget), then update gl.viewport and the camera's aspect ratio and render once — all inside the callback, so the new size and the new frame appear together.

Problem / Scenario Context

A product configurator renders a 3D model in a WebGL canvas that fills a resizable panel. The original code checks canvas.clientWidth against canvas.width at the top of every animation frame and resizes if they differ. It mostly works, but dragging the panel divider shows a stretched model for a frame, then a black flash, then the correct image; on a 4K laptop at 250% scaling, the canvas's drawing buffer is so large that frame rate drops to 20 fps. When the panel is hidden in a tab, the render loop keeps running at full cost.

WebGL canvases have the same sizing requirements as 2D canvases — covered in resizing a canvas with ResizeObserver without blurring — plus GPU-specific ones: the viewport, the projection, and a fill-rate budget.

Mechanics Explanation

A canvas has two sizes. Its CSS size is how big it appears; its drawing buffer size (canvas.width/height) is how many pixels the GPU renders. If they differ, the browser scales the buffer to the CSS size — blurry if the buffer is smaller, wasted work if larger.

Setting canvas.width or height clears the drawing buffer (to transparent black) and resizes it. If you resize and do not draw in the same frame, the next paint shows the cleared buffer: the black flash. If you draw with the old viewport after resizing, the image is stretched or cropped.

WebGL does not track the canvas size automatically: gl.viewport(0, 0, w, h) must be updated, and any projection matrix built from the aspect ratio must be recomputed. WebGPU is similar: the context's current texture follows the canvas size, but depth textures and projection must be recreated.

ResizeObserver callbacks run after layout and before paint, so resize + viewport update + render inside the callback produces a correctly sized, correctly drawn frame in the same paint. The device-pixel-content-box option gives the exact device-pixel size the browser will composite, avoiding off-by-one blur on fractional DPRs.

Pixel count grows with the square of DPR. A 1200×800 CSS canvas is 0.96 MP at 1×, 3.84 MP at 2×, 6 MP at 2.5×. Fragment shaders run per pixel, so on high-DPR laptops the GPU may simply not keep up; capping the buffer and letting the browser upscale slightly is usually the right trade.

Resize in the Render Loop Versus in the ObserverTwo lanes across two frames. When the render loop checks the size at the start of the next animation frame, the frame after the layout change paints the old buffer stretched, then the resize clears it, producing a flash before the correct image. When the ResizeObserver callback resizes, updates the viewport and renders before paint, the first frame after the layout change is already correct.Panel divider dragged; layout changes in frame Nrender-loop checkframe N: stretchedresizeframe N+1 correctRO callbacklayoutresize + renderframe N correct0ms4ms8ms12ms16ms20ms24ms28ms32ms

Comparison Table: Sizing Strategies

Strategy Stretch or flash on resize Exact device pixels Works when hidden Cost when idle
Check clientWidth every rAF yes, one frame no runs anyway reads layout every frame
window.resize listener yes; misses panel resizes no n/a none
RO + contentBoxSize × dpr no nearly no entries while hidden none
RO + devicePixelContentBoxSize no yes no entries while hidden none
RO + device pixels + pixel budget no capped no entries while hidden none

Minimal Reproducible Example

TypeScript
function frame(): void {
  const dpr = devicePixelRatio;
  const w = Math.round(canvas.clientWidth * dpr), h = Math.round(canvas.clientHeight * dpr);
  if (canvas.width !== w || canvas.height !== h) {
    canvas.width = w; canvas.height = h;        // clears the buffer, one frame late
    gl.viewport(0, 0, w, h);
  }
  render();
  requestAnimationFrame(frame);
}

declare const canvas: HTMLCanvasElement; declare const gl: WebGL2RenderingContext;
declare function render(): void;

Production-Safe Solution

TypeScript
interface GLView {
  canvas: HTMLCanvasElement;
  gl: WebGL2RenderingContext;
  render: (w: number, h: number) => void;   // draws one frame for the given buffer size
  maxPixels?: number;                        // fill-rate budget, e.g. 4 MP
}

export function attachGLResize({ canvas, gl, render, maxPixels = 4_000_000 }: GLView): () => void {
  const apply = (dpW: number, dpH: number): void => {
    // Cap the pixel count, preserving aspect ratio.
    const scale = Math.min(1, Math.sqrt(maxPixels / (dpW * dpH)));
    const w = Math.max(1, Math.round(dpW * scale));
    const h = Math.max(1, Math.round(dpH * scale));
    if (canvas.width === w && canvas.height === h) return;
    canvas.width = w;                       // clears the buffer…
    canvas.height = h;
    gl.viewport(0, 0, w, h);
    render(w, h);                           // …so draw immediately, before paint
  };

  const ro = new ResizeObserver(([e]) => {
    const dp = e.devicePixelContentBoxSize?.[0];
    if (dp) return apply(dp.inlineSize, dp.blockSize);
    const css = e.contentBoxSize[0];
    apply(Math.round(css.inlineSize * devicePixelRatio), Math.round(css.blockSize * devicePixelRatio));
  });

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

  return () => ro.disconnect();
}

The render loop no longer checks sizes; it only draws when the scene changes or animates. Resizing happens exclusively in the observer callback, which also renders, so the first painted frame after any layout change is correct. The budget keeps fill rate bounded on very high-DPR screens; render(w, h) should rebuild the projection matrix from w / h.

Pair it with an IntersectionObserver to stop the animation loop when the canvas is off-screen — the observer delivers nothing while the panel is hidden, but a rAF loop would otherwise keep rendering an invisible canvas:

TypeScript
let running = false;
const vis = new IntersectionObserver(([e]) => {
  if (e.isIntersecting && !running) { running = true; requestAnimationFrame(loop); }
  if (!e.isIntersecting) running = false;
});
vis.observe(canvas);
function loop(): void { if (!running) return; drawAnimatedFrame(); requestAnimationFrame(loop); }

declare function drawAnimatedFrame(): void;

What Happens in the Resize CallbackFive steps. The observer delivers the canvas's device-pixel size. The size is scaled down if it exceeds the pixel budget. The drawing buffer is resized, which clears it. The WebGL viewport and projection are updated for the new size. A frame is rendered immediately so the cleared buffer is never painted.1Device-pixel sizeFrom devicePixelContentBoxSize, or CSS size × DPR.2Apply the budgetScale down to at most maxPixels, keeping the aspect ratio.3Resize the buffercanvas.width / height — clears the buffer.4Viewport + projectiongl.viewport and the camera's aspect ratio.5Render nowBefore paint: no flash, no stretch.

WebGPU Notes

WebGPU follows the same principles with different calls. The canvas context's texture follows canvas.width/height on the next getCurrentTexture(), but any depth or multisample textures you created at the old size must be recreated, and render pass descriptors that reference them updated. Recreating textures on every pixel of a drag-resize is expensive; quantising the buffer size to multiples of 8 or 16 device pixels reduces reallocations with no visible cost, because the browser's final upscale absorbs the difference.

TypeScript
const q = (n: number) => Math.max(8, Math.round(n / 8) * 8);   // quantise to 8 device pixels

Apply the same quantisation in WebGL if framebuffer attachments (post-processing chains, shadow maps sized from the canvas) are expensive to recreate.

Pixels Rendered per Frame by Device Pixel RatioA bar chart of the drawing buffer size for a twelve hundred by eight hundred CSS pixel canvas. At a device pixel ratio of one it is about one megapixel. At two it is about three point eight megapixels. At two and a half it is six megapixels. With a four megapixel budget, the two and a half case is capped at four megapixels.1200 × 800 CSS px canvasDPR 10.96 MPDPR 23.84 MPDPR 2.5, uncapped6.0 MPDPR 2.5, 4 MP budget4.0 MP

Verification Steps

  • Drag the panel divider slowly and quickly; there should be no black flash or stretched frame.
  • Test on a fractional-DPR display (125% or 150% scaling) and check thin lines for blur.
  • Check frame rate on a high-DPR laptop with and without the pixel budget.
  • Hide the panel in a tab and confirm the render loop stops.
  • Zoom the page and confirm the device-pixel observer (where supported) resizes the buffer.

Common Mistakes to Avoid

  • Resizing in the render loop. The size check lags a frame behind layout.
  • Resizing without rendering. Setting width/height clears the buffer.
  • Forgetting gl.viewport. Drawing uses the old viewport and crops or stretches.
  • Unbounded DPR scaling. Fill rate on high-DPR screens can halve the frame rate.

FAQ

Why does resizing the canvas cause a black flash?

Setting canvas.width or canvas.height clears the drawing buffer. If the page paints before you draw again, the cleared buffer is visible. Rendering in the same ResizeObserver callback avoids that.

Should I use preserveDrawingBuffer to avoid the flash?

No. It does not survive a resize, which always reallocates the buffer, and it disables optimisations for every frame. Rendering immediately after resizing is the fix.

Is it wasteful to render inside the resize callback when the loop will render anyway?

It renders one extra frame per resize, which is negligible. The alternative is at least one visibly wrong frame.

What pixel budget should I use?

Two to four megapixels suits most interactive 3D scenes on laptops. Heavy post-processing may need less; simple scenes can afford more. Measure frame time on your slowest supported device.

Does this work with three.js?

Yes. Call renderer.setSize(w, h, false) with the buffer size — the false keeps three.js from setting CSS size — update camera.aspect and call camera.updateProjectionMatrix(), then render, all inside the observer callback.

Why not simply set the buffer to the CSS size times devicePixelRatio?

On fractional device pixel ratios, that product can differ by a pixel from what the browser composites, producing slight blur. devicePixelContentBoxSize gives the exact value where supported.


↑ Back to Responsive Canvas & Chart Resizing