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.

Interleaved Versus Batched CallbackTwo columns. The interleaved callback writes a width, reads scrollWidth, writes a class, and repeats for each cell, forcing a layout on every read after the first. The batched callback first reads every measurement it needs, while layout is still clean, then performs all writes, causing no forced layouts and only the one layout the frame needed anyway.Interleaved: write, read, write…Set width on cell 1, then read its scrollWidthLayout is dirty, so the read forces a layoutRepeat for 40 cells: 40 forced layouts per frameBatched: read all, then write allRead every scrollWidth first, while layout is cleanThen set all widths and classesOne layout for the frame, no forced reflows

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

TypeScript
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

TypeScript
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.

Layouts per Resize FrameA bar chart of layout passes per frame while resizing a forty-column table. The interleaved callback causes about forty-one layouts per frame: one for the frame and forty forced. The batched callback causes two: the frame's own layout and one after the callback's writes. Caching label widths keeps it at two even on the first resize after load.40-column table, one frame of a window resizeinterleaved write/read41 layoutsbatched reads then writes2 layoutsbatched + cached widths2 layouts, fewer reads

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:

TypeScript
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.

Locating the Forcing LineThree boxes. A forced Layout block nested under the callback is selected in the trace. The Summary pane shows Layout Forced with a JavaScript stack. The first frame from your own code names the file and line that read layout after a write.Forced Layout blocknested under the callbackSummary paneLayout Forced, with a stackFirst own framethe read-after-write line

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 requestAnimationFrame to "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.


↑ Back to Profiling Observer Performance in DevTools