content-box and border-box observation and the contentBoxSize/borderBoxSize entry fields are supported in all current engines; device-pixel-content-box and devicePixelContentBoxSize are supported in Chromium and Firefox but not in Safari, so feature-detect the device-pixel option with try/catch and fall back to rounding the CSS size times devicePixelRatio.
Problem / Scenario Context
A mapping library renders tiles into a canvas and sizes its backing store from ResizeObserver. The team adopted device-pixel-content-box after reading that it avoids blurry canvases on fractional-DPR screens, and it works perfectly in Chrome. In Safari the whole map fails to initialise: observe() throws a TypeError for the unknown enum value, the exception escapes the setup function, and the map container stays empty.
Box options arrived later than the observer itself, and not everywhere at once. The Browser Compatibility & Polyfills topic covers overall support; this page covers the box options specifically.
Mechanics Explanation
ResizeObserver.observe(target, { box }) accepts a ResizeObserverBoxOptions enum value:
content-box(default) — deliver when the content box changes.border-box— deliver when the border box changes.device-pixel-content-box— deliver when the content box in device pixels changes; this includes zoom and moving the window between monitors with different pixel ratios, even if the CSS size is unchanged.
Because box is an enum, an unknown value is a WebIDL type error: the call throws synchronously. That is different from unknown dictionary members, which are ignored. On the entry side, fields that an engine does not support are simply undefined.
The first shipped version of the API had only contentRect; contentBoxSize and borderBoxSize followed, and Firefox initially exposed them as a single object rather than an array before aligning with the specification. Code that must run on older engines should handle all three shapes.
Comparison Table: Failure Modes
| Unsupported feature | What happens | Detection | Fallback |
|---|---|---|---|
box: 'device-pixel-content-box' |
observe() throws TypeError |
try/catch a probe observe |
observe content-box |
devicePixelContentBoxSize field |
undefined |
check the field on the entry | Math.round(css × dpr) |
contentBoxSize field (very old engines) |
undefined |
prototype check | contentRect |
contentBoxSize as object not array (old Firefox) |
[0] is undefined |
Array.isArray |
use the object directly |
| DPR change without CSS change | no entry on content-box |
n/a | listen to matchMedia('(resolution: …)') |
Minimal Reproducible Example
function initMap(canvas: HTMLCanvasElement): void {
const ro = new ResizeObserver(([e]) => {
const dp = e.devicePixelContentBoxSize[0]; // undefined in Safari → TypeError
canvas.width = dp.inlineSize;
canvas.height = dp.blockSize;
drawTiles();
});
ro.observe(canvas, { box: 'device-pixel-content-box' }); // throws in Safari
}
declare function drawTiles(): void;
Production-Safe Solution
function supportsDevicePixelBox(): boolean {
const ro = new ResizeObserver(() => {});
try { ro.observe(document.createElement('div'), { box: 'device-pixel-content-box' }); return true; }
catch { return false; }
finally { ro.disconnect(); }
}
function firstSize(v: ReadonlyArray<ResizeObserverSize> | ResizeObserverSize | undefined) {
return Array.isArray(v) ? v[0] : (v as ResizeObserverSize | undefined);
}
export function observeCanvas(canvas: HTMLCanvasElement, draw: () => void): () => void {
const useDevicePx = supportsDevicePixelBox();
const apply = (e: ResizeObserverEntry): void => {
const dp = firstSize(e.devicePixelContentBoxSize);
const css = firstSize(e.contentBoxSize);
const cssW = css?.inlineSize ?? e.contentRect.width;
const cssH = css?.blockSize ?? e.contentRect.height;
const w = dp?.inlineSize ?? Math.round(cssW * devicePixelRatio);
const h = dp?.blockSize ?? Math.round(cssH * devicePixelRatio);
if (canvas.width !== w || canvas.height !== h) {
canvas.width = w;
canvas.height = h;
draw();
}
};
const ro = new ResizeObserver((entries) => entries.forEach(apply));
ro.observe(canvas, { box: useDevicePx ? 'device-pixel-content-box' : 'content-box' });
// Without the device-pixel box, DPR changes (zoom, moving monitors) deliver no entry.
let mq: MediaQueryList | null = null;
const onDpr = (): void => {
mq?.removeEventListener('change', onDpr);
mq = matchMedia(`(resolution: ${devicePixelRatio}dppx)`);
mq.addEventListener('change', onDpr);
const r = canvas.getBoundingClientRect();
apply({ contentRect: r, contentBoxSize: undefined, devicePixelContentBoxSize: undefined } as unknown as ResizeObserverEntry);
};
if (!useDevicePx) onDpr();
return () => { ro.disconnect(); mq?.removeEventListener('change', onDpr); };
}
Where the device-pixel box is available, the browser tells you the exact snapped size and notifies you on zoom. Where it is not, the fallback rounds the CSS size times devicePixelRatio — occasionally one pixel off from the browser's own snapping, which is rarely visible — and a resolution media query listener catches DPR changes that content-box observation would miss.
How Much the Fallback Costs You
The practical question is whether the fallback is visibly worse. For most canvases it is not; for some it is:
- Integer DPR (1, 2, 3) — CSS sizes that are whole pixels multiply exactly, and rounding matches the browser's snapping. No difference.
- Fractional DPR (1.25, 1.5, 2.625) — common on Windows laptops and some Android phones. A canvas at a fractional CSS position can be snapped one device pixel differently from the rounded value, producing a backing store that is one pixel too wide or narrow and is scaled by a hair — visible as slight blur on thin lines and text.
- Pixel-art and charts with 1px lines — the most sensitive; a one-pixel mismatch makes lines alternate between sharp and soft.
Safari's desktop and iOS devices use integer ratios, which is part of why the missing option rarely produces visible artefacts there. The deep dive in using devicePixelContentBox for crisp canvas shows the snapping arithmetic.
Verification Steps
- Run
supportsDevicePixelBox()in each target browser and confirm it matches the support grid. - Zoom the page in Safari and confirm the fallback's media query listener resizes the canvas.
- Move a window between monitors with different DPRs and confirm the canvas stays crisp.
- Test on a 1.25× or 1.5× Windows display and inspect thin lines for blur in the fallback path.
- Confirm initialisation never throws by running the setup in Safari with the console's pause-on-exceptions enabled.
Common Mistakes to Avoid
- Passing the device-pixel box unguarded. It throws in Safari.
- Reading
devicePixelContentBoxSize[0]without a check. It isundefinedwhere unsupported. - Forgetting DPR changes in the fallback.
content-boxobservation does not notice zoom. - Using
Math.floororMath.ceil. Rounding is closest to how browsers snap.
FAQ
Does Safari support device-pixel-content-box?
Not at the time of writing. Safari supports content-box and border-box observation and the corresponding size arrays, but passing device-pixel-content-box throws, and entries have no devicePixelContentBoxSize.
Why does an unsupported box value throw instead of being ignored?
Because box is an enumeration. WebIDL treats an unknown enum value as a type error, while unknown dictionary members are ignored.
Do I need border-box support detection?
Not for current browsers; content-box and border-box are universally supported. Only very old engines lacked the box option entirely, and there it was ignored rather than throwing.
Is the rounding fallback good enough?
For integer device pixel ratios it is exact. For fractional ratios it can be one device pixel off, which matters only for very thin lines or pixel art.
Why does the fallback need a resolution media query?
Because content-box observation only fires when the CSS size changes. Browser zoom and moving a window to a monitor with a different pixel ratio change the device-pixel size without changing the CSS size, so without the media query the canvas keeps a stale backing store and looks blurry until the next real resize.
Should I observe border-box for a canvas?
No. A canvas's drawing buffer maps onto its content box. Borders and padding sit outside the drawing surface, so sizing the backing store from the border box would stretch the drawing slightly.
Is it worth detecting support on every page load?
The probe costs one observer construction and one observe call, which is negligible. Cache the result for the page's lifetime so every canvas on the page reuses it instead of probing again.
Can a polyfill add device-pixel-content-box?
Not accurately. The value depends on the browser's internal pixel snapping, which script cannot observe directly. The fallback approximates it, which is as close as a polyfill could get.
Related
- contentRect vs borderBoxSize: Which to Read — the fields explained
- Feature-Detecting Observer APIs Safely — detection in general
- Resizing WebGL Canvases with ResizeObserver — the WebGL case
↑ Back to Browser Compatibility & Polyfills for Observer APIs