ResizeObserver always delivers one entry for each newly observed element at the next rendering opportunity — even if nothing changed — and then often more during page load, as web fonts, images, scrollbars and late CSS change the layout; treat the first entry as initialisation and make later ones cheap and idempotent.
Problem / Scenario Context
A dashboard renders six charts, each redrawn from a ResizeObserver callback. The team notices each chart drawing three or four times in the first second of every page load, even though nobody resized anything. On slower phones the repeated draws push interactivity back by several hundred milliseconds. Someone proposes ignoring the first callback with a flag — which then leaves charts at the wrong size when the first entry was the only one.
Neither "ignore it" nor "draw every time" is right. The ResizeObserver Mechanics & Triggers topic lists what counts as a size change; this page explains the load-time sequence specifically.
Mechanics Explanation
The initial observation. When you call observe(), the browser records the element's "last reported size" as zero by zero. At the next rendering update, the element's real size differs from 0×0, so it is delivered. That is deliberate: it guarantees every observer gets the current size once, without you having to read it separately. An element that is genuinely 0×0 — display: none, detached, or empty — gets no initial entry, which is why ResizeObserver does not fire on display: none elements.
Late layout changes during load. After the first paint, several things commonly change sizes without any user action:
- Web fonts swap in (with
font-display: swap), changing text metrics and reflowing blocks. - Images without dimensions decode and take up space, pushing siblings and resizing containers.
- Scrollbars appear once content exceeds the viewport height, narrowing every full-width element by the scrollbar's width on classic-scrollbar platforms.
- Late stylesheets or CSS-in-JS inject rules after hydration.
- Framework hydration replaces placeholder content with real content.
Each of these is a genuine size change, and each produces another entry for affected observed elements. The dashboard's three or four draws were: initial, scrollbar, font swap, and a hydration change.
Comparison Table: Load-Time Triggers and Their Fixes
| Trigger | Why it resizes | Prevent at the source | Or absorb in the callback |
|---|---|---|---|
| Initial observation | 0×0 → real size | cannot, by design | use it as initialisation |
| Scrollbar appearance | viewport narrows by ~15px | scrollbar-gutter: stable |
idempotent redraw |
| Web font swap | text metrics change | size-adjust / metric overrides, preload fonts |
quantise sizes |
| Images without dimensions | box grows on decode | width/height attributes |
— |
| Hydration content swap | placeholders differ | SSR the real content or size placeholders | skip unchanged sizes |
| Late CSS | rules change layout | inline critical CSS | — |
Minimal Reproducible Example
let n = 0;
const ro = new ResizeObserver((entries) => {
for (const e of entries) {
n++;
console.log(`#${n}`, e.target.id, Math.round(e.contentBoxSize[0].inlineSize), performance.now().toFixed(0));
drawChart(e.target as HTMLElement); // expensive
}
});
document.querySelectorAll('.chart').forEach((c) => ro.observe(c));
declare function drawChart(el: HTMLElement): void;
Reload with the cache disabled: each chart logs several entries, with inline sizes that differ by a scrollbar's width and then by a few pixels after the font swap.
Production-Safe Solution
Fix what can be fixed at the source, then make the callback skip work when the size that matters has not changed.
/* Reserve scrollbar space up front, so its appearance changes nothing. */
html { scrollbar-gutter: stable; }
/* Reduce font-swap reflow with metric-compatible fallbacks. */
@font-face {
font-family: 'Inter Fallback';
src: local('Arial');
size-adjust: 107%;
ascent-override: 90%;
}
body { font-family: 'Inter', 'Inter Fallback', system-ui, sans-serif; }
interface Drawn { w: number; h: number }
const lastDrawn = new WeakMap<Element, Drawn>();
const STEP = 4; // px; below this, redrawing is invisible
const ro = new ResizeObserver((entries) => {
for (const e of entries) {
const box = e.contentBoxSize[0];
const w = Math.round(box.inlineSize / STEP) * STEP;
const h = Math.round(box.blockSize / STEP) * STEP;
const prev = lastDrawn.get(e.target);
if (prev && prev.w === w && prev.h === h) continue; // meaningful size unchanged
lastDrawn.set(e.target, { w, h });
drawChart(e.target as HTMLElement, w, h);
}
});
document.querySelectorAll('.chart').forEach((c) => ro.observe(c));
declare function drawChart(el: HTMLElement, w: number, h: number): void;
The first entry always draws, because there is no previous size. Later entries draw only if the size moved by at least one quantisation step. With scrollbar-gutter: stable and font metric overrides in place, most load-time entries fall below that threshold and cost nothing.
Why Not Skip the First Entry?
Skipping the first entry is tempting when a component already knows its initial size — for example, from server-rendered dimensions. It is fragile for three reasons:
- The first entry may be the only one. On a warm cache with fonts already loaded and no scrollbar change, there is no second entry, and a skipped first entry leaves the component uninitialised.
- Component sizes are not known before layout. A server-rendered guess can be wrong on any device whose viewport differs from the guess.
- Order is not guaranteed across observers. Code that assumes "the first callback for this element is the initial one" breaks if the element was observed earlier by a shared observer.
The quantised comparison above achieves the intent — "do not redo work for an unchanged size" — without assuming anything about which entry is first. If the server-rendered size is accurate, seed lastDrawn with it and the first entry will be skipped naturally when it matches.
Verification Steps
- Log entries with timestamps on a cold load and identify each one's cause (scrollbar, font, hydration).
- Add
scrollbar-gutter: stableand confirm the scrollbar entry disappears on Windows and Linux. - Check a warm load and confirm the component still initialises from the first entry.
- Count expensive draws with a counter in the draw function; one per chart on load is the goal.
- Throttle the network so fonts load late, and confirm the swap does not cause a visible redraw.
Common Mistakes to Avoid
- Ignoring the first callback with a flag. It may be the only one you get.
- Redrawing on sub-pixel changes. Quantise to what is visible.
- Leaving images without dimensions. Every late decode resizes containers and re-fires observers.
- Fighting scrollbar appearance in script. One CSS property prevents it.
FAQ
Why does ResizeObserver fire even though nothing resized?
Because observe() records the last reported size as zero, and the element's real size differs from zero at the next rendering update. The initial entry is intentional: it gives every observer the current size without a separate read.
Does the initial entry arrive synchronously when I call observe?
No. It arrives at the next rendering opportunity, in the rendering steps after layout, like every other ResizeObserver entry.
Why do I get an entry when the scrollbar appears?
On platforms with classic scrollbars, the scrollbar takes space from the viewport, narrowing every element sized to it. That is a real content-box change. scrollbar-gutter: stable reserves the space from the start.
Can I get the initial size without the callback?
You can read getBoundingClientRect or offsetWidth yourself, but that forces layout at the moment you call it and duplicates what the initial entry will tell you anyway. Letting the first entry initialise the component is usually cheaper.
Do elements with zero size get an initial entry?
No. Their size matches the recorded zero, so nothing is delivered until they gain a non-zero size — which is why hidden or collapsed elements seem to be ignored.
Related
- contentRect vs borderBoxSize: Which to Read — the sizes in each entry
- Should You Debounce ResizeObserver Callbacks? — when deferring helps
- Observer Callback Not Called on Initial Render — the opposite symptom
↑ Back to ResizeObserver Mechanics & Triggers