Inside a ResizeObserver callback the first layout read is free because layout was just computed, but any read that follows a style write forces a new synchronous layout — so read everything you need from the entries (or in one pass) first, then do all writes, and never interleave the two.
Problem / Scenario Context
A data table component uses a ResizeObserver on its container to decide column widths. The callback loops over the header cells: for each one it sets a width, then reads the cell's scrollWidth to check whether the label overflows, and if so adds a class that switches to an abbreviated label. Resizing the window is visibly sluggish, and a trace shows each frame containing dozens of purple Layout blocks nested inside the callback, each flagged "Forced reflow".
The callback's own script time is under a millisecond. The cost is the forty layouts it causes. This is the most common hidden cost found when profiling observer performance.
Mechanics Explanation
Layout is lazy. When script changes something that affects geometry — a width, a class, text content — the browser marks layout as dirty and carries on. It only recomputes layout when it must: at the next rendering opportunity, or immediately if script asks for a geometry value while layout is dirty. The immediate case is a forced synchronous layout, or forced reflow.
ResizeObserver callbacks run right after the browser's own layout, so on entry layout is clean: the first read of offsetWidth, getBoundingClientRect() or scrollWidth returns a cached value at no cost. The trouble starts after the first write. Every read after a write finds layout dirty and forces it. A loop that writes then reads for each of N cells forces N layouts.
The fix pattern is old — batch reads, then batch writes — but in a ResizeObserver callback it has an extra advantage: the entries themselves carry fresh sizes, so many reads are unnecessary altogether.
Comparison Table: Reads That Force Layout
| Property or method | Forces layout when dirty? | Alternative inside RO callback |
|---|---|---|
offsetWidth, offsetHeight, offsetTop |
yes | entry.borderBoxSize |
clientWidth, clientHeight |
yes | entry.contentBoxSize (+ padding if needed) |
getBoundingClientRect() |
yes | entry sizes for size; position still needs a read |
scrollWidth, scrollHeight |
yes | read once before writes |
getComputedStyle(el).width |
yes (layout-dependent values) | entry sizes |
getComputedStyle(el).color |
style only | cache; avoid in loops |
entry.contentRect, contentBoxSize |
no — precomputed | — |
Minimal Reproducible Example
const ro = new ResizeObserver(function fitHeaders([entry]) {
const width = entry.contentBoxSize[0].inlineSize;
const cells = entry.target.querySelectorAll<HTMLElement>('th');
const each = width / cells.length;
cells.forEach((th) => {
th.style.width = `${each}px`; // write → layout dirty
th.classList.toggle('abbr', th.scrollWidth > th.clientWidth); // read → FORCED layout
});
});
ro.observe(document.querySelector('.table-wrap')!);
Record a trace while dragging the window edge. Each frame shows one forced Layout per header cell nested under fitHeaders.
Production-Safe Solution
interface HeaderFit { th: HTMLElement; labelWidth: number }
// Measure label widths once, when not resizing, and cache them.
const labelWidths = new WeakMap<HTMLElement, number>();
function measureLabels(cells: HTMLElement[]): void {
for (const th of cells) {
const label = th.querySelector<HTMLElement>('.label');
if (label && !labelWidths.has(th)) labelWidths.set(th, label.scrollWidth); // reads only
}
}
const ro = new ResizeObserver(function fitHeaders(entries) {
for (const entry of entries) {
const width = entry.contentBoxSize[0].inlineSize; // no read needed
const cells = [...entry.target.querySelectorAll<HTMLElement>('th')];
const each = width / cells.length;
// Phase 1 — READ: everything we need, while layout is still clean.
measureLabels(cells);
const fits: HeaderFit[] = cells.map((th) => ({ th, labelWidth: labelWidths.get(th) ?? 0 }));
// Phase 2 — WRITE: no reads below this line.
for (const { th, labelWidth } of fits) {
th.style.width = `${each}px`;
th.classList.toggle('abbr', labelWidth > each - 16); // 16px padding, known from CSS
}
}
});
Label widths do not change when the column resizes, so they are measured once and cached in a WeakMap; the overflow decision is then arithmetic. The callback now causes no forced layouts — the browser performs one layout after the callback returns, which it had to do anyway to apply the new widths. The general technique is covered in batching DOM reads and writes in observer callbacks.
Finding the Forcing Line Quickly
In a large codebase the forcing read may be several calls deep — inside a utility, a framework helper or a third-party chart library. Three techniques locate it fast:
The trace's stack. Click any forced Layout block in the Performance panel. The Summary pane shows "Layout Forced" and a stack trace of the JavaScript that triggered it. The first frame in your own code is the culprit.
A temporary tripwire. In development, wrap the usual suspects to log when they are called inside a callback after a write:
let wroteThisCallback = false;
const origOffset = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'offsetWidth')!;
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
get() { if (wroteThisCallback) console.trace('read after write'); return origOffset.get!.call(this); },
});
// Set wroteThisCallback = true after the first style write in the callback, false at the end.
Third-party code. When the read is inside a library you cannot change, call the library outside the write phase — or, if the library both reads and writes internally, call it once per frame from a single place rather than once per element.
Verification Steps
- Record a trace while resizing and confirm no Layout blocks are nested inside the callback.
- Check the task warnings; "Forced reflow" should no longer appear on the resize frames.
- Count Layouts per frame in the flame chart: at most two during a resize.
- Test with many columns or items, where the difference is largest.
- Keep a performance test that asserts on frame duration while resizing, if the component is critical.
Common Mistakes to Avoid
- Assuming the callback is cheap because its script time is small. Forced layout time is attributed underneath it but easy to overlook.
- Reading computed styles in a loop. Even style-only reads force style recalculation after writes.
- Measuring things that do not change on resize. Cache them outside the hot path.
- Wrapping writes in
requestAnimationFrameto "fix" it. It moves them to the next frame, causing a visible one-frame lag — the reason the component used a resize observer in the first place.
FAQ
Why is the first read inside a ResizeObserver callback free?
Because the callback runs immediately after the browser's own layout pass in the rendering steps. Nothing has been changed since, so layout is clean and reads return cached values.
Does reading entry.contentRect ever force layout?
No. Entry sizes are computed by the browser before the callback runs and stored on the entry object. They are plain data.
Is a forced layout always bad?
One forced layout that replaces the frame's own layout costs nothing extra. The problem is repeated forcing — read, write, read, write — where each read pays for a full layout of the affected subtree.
What about IntersectionObserver callbacks?
They run in a task after paint, when layout is also clean, so the same rule applies: reads before writes. Their entries carry boundingClientRect, which avoids most reads.
Does reading entry.target.getBoundingClientRect() inside the callback force layout?
Not if it happens before any write in the callback, because layout is still clean at that point. It becomes a forced layout only after the callback, or an earlier callback in the same delivery, has changed something that affects geometry.
Can CSS remove the need for measurement entirely?
Often. Container queries can switch to abbreviated labels based on the column's width without any script, and text-overflow: ellipsis handles simple truncation. Reach for a ResizeObserver only when the decision needs information CSS cannot express.
Related
- Reducing Layout Thrashing with ResizeObserver — the broader thrashing problem
- Caching getBoundingClientRect Results in Observer Callbacks — caching reads that must happen
- Reading Observer Callbacks in the Chrome Performance Panel — where forced layouts show up