Multiplying the content box by devicePixelRatio is the standard advice and it is very nearly right. The residue — a fraction of a pixel, arriving only at certain container widths — is what devicePixelContentBoxSize exists to eliminate.

Problem / Scenario Context

A chart is sized correctly by the four-step resize sequence and looks sharp at most window widths. At a few specific widths the gridlines go soft. Dragging the window a pixel wider fixes it; a pixel narrower breaks it again.

This is not a bug in the resize code. It is a rounding disagreement. The container is 300.5 CSS pixels wide because flexbox distributed a remainder; multiplied by a ratio of 2 that is 601 device pixels, which gets rounded to 601. But the browser laid the element out across 600 device pixels, or 602, depending on where the box starts on the device grid. The backing store and the display area are one device pixel apart, the browser scales to compensate, and every hairline in the chart is resampled.

Mechanics Explanation

ResizeObserver can report three different boxes, selected by the box option passed to observe():

  • content-box — the default; CSS pixels, fractional.
  • border-box — CSS pixels including padding and border.
  • device-pixel-content-box — the content area measured in device pixels, as integers.

The third is not simply the first multiplied by the ratio. It is what the compositor actually allocated, which already accounts for where the element's box lands on the device pixel grid. Two elements of identical CSS width can have device-pixel widths that differ by one, depending on their subpixel offsets, and only this box knows that.

The field is populated only when you asked for that box. Reading entry.devicePixelContentBoxSize on an observer created without the option gives undefined, which is the most common reason people conclude it is unsupported.

601 or 600? The Rounding the Multiplication Cannot KnowA container of 300.5 CSS pixels on a display with a pixel ratio of two. Multiplying and rounding gives a backing store of 601 device pixels. The compositor allocated 600, because of where the element's box falls on the device grid. The one-pixel disagreement forces a rescale of the whole bitmap, softening every hairline. The device pixel content box reports 600 directly.Container: 300.5 CSS px, devicePixelRatio 2multiply and roundMath.round(300.5 * 2) = 601an educated guess about the layoutdevicePixelContentBoxSizeinlineSize = 600what the compositor actually allocatedA 601-wide bitmap shown in a 600-wide area is rescaled by 0.998 — imperceptible on photos, visible on every 1px line.The disagreement appears and disappears as the container's subpixel offset changes, which is why it looks intermittentand why dragging the window a single pixel seems to fix it.

Comparison Table: Box Options

box option Field populated Units Use when
omitted (content-box) contentBoxSize, contentRect CSS, fractional text reflow, layout decisions
'border-box' borderBoxSize CSS, fractional the element's footprint on the page
'device-pixel-content-box' devicePixelContentBoxSize and the others device pixels, integer canvas and WebGL backing stores

Asking for the device-pixel box does not cost you the others — the entry still carries contentBoxSize and contentRect, so a callback can use the CSS box for layout decisions and the device box for the backing store from the same entry.

Minimal Reproducible Example

TypeScript
// Returns undefined: the box was never requested
const ro = new ResizeObserver(([entry]) => {
  console.log(entry.devicePixelContentBoxSize);   // undefined
});
ro.observe(shell);                                 // no options

Production-Safe Solution

TypeScript
type Backing = { w: number; h: number; ratio: number; exact: boolean };

function backingFrom(entry: ResizeObserverEntry): Backing {
  const ratio = window.devicePixelRatio || 1;

  const dp = entry.devicePixelContentBoxSize?.[0];
  if (dp && dp.inlineSize > 0) {
    return { w: dp.inlineSize, h: dp.blockSize, ratio, exact: true };
  }

  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 * ratio), h: Math.round(cssH * ratio), ratio, exact: false };
}

function observeExact(ro: ResizeObserver, el: Element): boolean {
  try {
    ro.observe(el, { box: 'device-pixel-content-box' });
    return true;
  } catch {
    ro.observe(el);          // engines that do not know the option throw rather than ignore it
    return false;
  }
}

The exact flag is worth carrying. When it is false the backing store is a best guess, which is a reason to be slightly more generous with line widths — a hairline drawn at exactly 1 device pixel is the first thing to soften under a fractional rescale, whereas 1.5 survives it.

For WebGL the same numbers feed a different call:

TypeScript
const { w, h } = backingFrom(entry);
canvas.width = w;
canvas.height = h;
gl.viewport(0, 0, w, h);     // setTransform has no equivalent here — the viewport IS the mapping

Opting In, and Falling BackA decision path at observe time. Requesting the device pixel content box inside a try block either succeeds, in which case entries carry the exact integer size, or throws on engines that do not recognise the option, in which case a plain observe is issued and the callback multiplies the CSS box by the pixel ratio. Both paths converge on the same backing-store assignment.observe(el, { box:'device-pixel-…' })okentry carries the exact boxexact: truethrowsplain observe, multiplyexact: falsecanvas.width = wsame assignment either wayThe option throws where it is unknown — so it must be requested inside a tryCarrying the exact flag lets the drawing code widen hairlines slightly on the approximate path.

When the Difference Is Worth the Code

Not every canvas needs the exact box, and it is worth being honest about where the difference is perceptible.

A one-device-pixel disagreement produces a rescale factor within a fraction of a per cent. On photographic content, gradients or anything with soft edges, that is genuinely invisible — the resampling has nothing sharp to blur. On content built from thin straight lines it is immediately visible, because a one-pixel line resampled across a fractional offset becomes two half-intensity lines, and the eye is extremely good at spotting that.

So the rule is about content, not about correctness. Charts with gridlines, sparklines, technical diagrams, anything with 1-pixel strokes or small text rendered into the canvas: use the exact box. Heat maps, particle fields, image compositing, WebGL scenes with no hairlines: the multiplication is fine and the fallback path will never be noticed.

There is a second reason to prefer it even where the visual difference is marginal, which is that it removes a whole category of intermittent bug report. "The chart is blurry sometimes, on some screens, at some window sizes" is expensive to triage precisely because it is not reproducible on demand, and the exact box makes it not happen at all.

Where the Fractional Case Comes FromA flex row of one thousand and one pixels distributing space between three equal children. Each child receives 333.67 CSS pixels, a value that cannot be represented on the device pixel grid, so the browser assigns 667 device pixels to some children and 668 to others. The exact device pixel box reports which; multiplying cannot.A 1001px flex row, three equal children, ratio 2333.67 css333.67 css333.67 cssmultiply and round: 667 for all threedevice pixel box: 667, 668, 667 — the compositor had to put the extra pixel somewhereOne of the three is soft, and which one changes as the row width changes. Hence the intermittent reports.

Verification Steps

  • Confirm the field is populated: log entry.devicePixelContentBoxSize?.[0]undefined means the option was not requested, not that support is missing.
  • Find a disagreement deliberately: set the container to a width that produces a fractional CSS box, such as a flex child of a 1001-pixel row, and compare the two computations.
  • Check on a 1× display: both paths should agree exactly, which confirms the fallback arithmetic.
  • Check gl.viewport for WebGL: a mismatch there produces a stretched scene rather than a soft one, which is easier to spot.

Common Mistakes to Avoid

  • Reading the field without requesting the box. The single most common cause of "it's not supported here".
  • Calling observe() with the option outside a try. Unknown box values throw, taking the rest of your setup with them.
  • Assuming the device box equals the CSS box times the ratio. If that were always true the field would not exist.
  • Using the device-pixel numbers for drawing coordinates. They size the backing store; the drawing still works in CSS units after setTransform.

FAQ

Why is devicePixelContentBoxSize undefined on my entries?

Because the box was not requested. The field is only populated for observers created with box set to device-pixel-content-box in the observe call. Reading it from a default observer gives undefined on every engine, including those that support it fully.

Is it not the same as contentBoxSize times devicePixelRatio?

Not always, and the exceptions are exactly the cases that matter. The device pixel box reflects where the element's box actually lands on the device grid after subpixel layout, so two elements with identical CSS widths can differ by one device pixel. If the multiplication were always correct the field would have no reason to exist.

What happens on engines that do not support the option?

The observe call throws rather than ignoring the unknown value, which is why it must be wrapped in a try. Catch it, call observe without options, and compute the backing store by multiplying — the result is right almost all the time and only slightly soft in the fractional cases.

Does requesting the device pixel box lose the CSS measurements?

No. The entry still carries contentBoxSize, borderBoxSize and contentRect, so one callback can use the CSS box for layout decisions and the device box for the backing store without needing two observers.


↑ Back to Responsive Canvas & Chart Resizing