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.
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
// 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
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:
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
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.
Verification Steps
- Confirm the field is populated: log
entry.devicePixelContentBoxSize?.[0]—undefinedmeans 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.viewportfor 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 atry. 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.
Related
- Resizing a Canvas with ResizeObserver Without Blurring — the sequence this measurement feeds
- ResizeObserver Mechanics & Triggers — the three box models the entry can report
- Fixing Chart Libraries That Ignore Container Resize — passing these numbers to code you do not own
↑ Back to Responsive Canvas & Chart Resizing