Web Platform · Rendering Performance

Forced Synchronous Layout and the Cost of Interleaving Reads with Writes

Why calling getBoundingClientRect after a style write can trigger a full layout pass, how to recognise the pattern, and the restructuring that collapses N passes into one.

The rendering pipeline is batched because writes do not take effect immediately. Setting element.style.height records an invalidation and returns; geometry is recomputed once, later, at the start of the next frame. That batching is what keeps a hundred mutations as cheap as one. It survives only until code asks a question whose answer depends on geometry, at which point the browser has no choice but to run layout immediately, inside the current task, before the read can return a value.

What the flush actually includes

A forced layout is not just layout. The read requires an up-to-date tree, so the engine runs, in order: style recalculation for everything dirty, then layout for everything dirty, then returns the requested value. Paint is not run. If the read is repeated after another write, exactly the same sequence runs again.

Which reads force which stages depends on the property:

  • getBoundingClientRect(), offsetWidth, offsetHeight, offsetTop, offsetLeft, scrollWidth, scrollHeight, clientWidth, clientHeight force style and layout. They return used values, which exist only after layout.
  • getComputedStyle() forces style recalculation. It may additionally force layout when the requested property is layout-dependent, because a computed value such as width is resolved against the layout.
  • scrollTop and scrollLeft reading can force layout, and on some platforms a scroll offset is only known after layout has clamped it.
  • elementFromPoint(), caretPositionFromPoint(), and getClientRects() force layout because they hit-test or measure real geometry.
  • window.getComputedStyle() on a pseudo-element behaves like the element case: style always, layout when the property needs it.

Reads that do not force layout include element.dataset, element.classList.contains, element.attributes, and any value the engine keeps in the DOM or in the style cache without resolving geometry.

A loop that lays out once per iteration

The pattern is a sequence of write, measure, write, measure. Each measure sees the previous write and must flush.

// Two reads and two writes per row: up to 2N layout passes.
for (const item of items) {
  item.style.width = container.clientWidth + 'px'; // read, then write
  const h = item.offsetHeight;                     // read after write -> flush
  item.style.marginTop = (h / 2) + 'px';           // write after read -> dirty
}

The cost is not a constant factor. A layout pass triggered from script runs synchronously within the task, so it cannot be overlapped with anything, and it is paid on the critical path of the input or timer that started the task. For an array of a thousand rows, the worst case is two thousand layout passes over a growing dirty tree; in practice many passes are cheap because little changed, and a few are expensive because the change reached a layout root high in the document. That mixture is why the pattern is often invisible until an unrelated CSS change makes containment worse.

The rewrite separates the phases: read everything first, then write everything.

// All reads first: at most one flush, whichever read finds the tree dirty.
const containerWidth = container.clientWidth;
const heights = items.map((item) => item.offsetHeight);
// Then all writes, batched into the frame's own layout pass.
items.forEach((item, i) => {
  item.style.width = containerWidth + 'px';
  item.style.marginTop = (heights[i] / 2) + 'px';
});

The first pass may still force one layout if the caller dirtied the tree, but the count is now bounded by the number of phases rather than by the number of elements.

Why the browser does not reorder the two

Deferring a read until after the batch is impossible without changing semantics. A read is an observation, and the program is allowed to branch on it. If offsetHeight returned a stale value, if (el.offsetWidth > 0) would take the wrong branch. So the flush is a correctness requirement rather than an implementation weakness: the language has no way to express “this measure may be satisfied later”, and the DOM API exposes geometry synchronously.

Browsers do optimise the common cases. Style reads are served from the computed-style cache when nothing is dirty; layout is skipped when no layout invalidation is pending. The expensive case is precisely the one described above: a write followed by a read.

The same trap inside observers

ResizeObserver callbacks run after layout and before paint, which means a write performed inside a resize callback does not need to force a layout to observe the new size of the changed element in the same pass — but it can still cause a second layout pass in the same frame. The specification defines a limit on the loop: notifications are delivered with a depth counter, and when the limit is reached the engine reports an error rather than looping forever. The familiar console message about undelivered notifications is that guard firing.

The practical reading is that a resize callback should be a write phase, not a measure-and-write phase. Measuring the target element inside the callback is legitimate — its size is already computed — but causing the observed element to resize again in response to its own size is a feedback loop by construction, and the loop guard will not make it cheap.

IntersectionObserver avoids the problem entirely because its callbacks are delivered asynchronously, not tied to the frame that changed the intersection, and they report intersections computed from a previous frame’s geometry.

Container queries, media queries, and matchMedia

matchMedia() does not force layout: the media query result depends on the viewport, not on the document’s boxes, and the engine tracks viewport size directly. window.innerWidth is likewise a property of the viewport rather than of the layout tree, so it does not flush.

Element-level queries are different. Reading a container’s measured size, or relying on a container query change in the same task, participates in layout because the query result feeds into style. An element whose style depends on its container’s size cannot be restyled until that size is known, which is why container queries can extend a layout dependency chain upward in a way plain media queries never do.

Detecting the pattern in a running page

A forced layout shows up as a layout event attributed to script in a performance trace, rather than to the frame’s own rendering work. The diagnostic is the duration of the task and the presence of a layout event inside it, followed by another layout event in the same task, repeated. The repetition count is the number of interleaved reads.

Because the flush happens inside the caller’s stack, the trace’s call stack for the layout event names the function performing the read. That is a direct answer to “which line”, unlike the batched case where the layout is attributed to the frame’s start.

For code that must measure repeatedly — a drag handler, a layout-thrashing animation — the structural fix is to move measurement to a single point per frame, usually the start of the animation callback, and keep all writes after it. The timestamp passed to the callback is the frame’s start time; measuring inside the callback after performing writes from the previous frame costs one layout that the frame was going to perform anyway.

When the read is unavoidable

Some measurements cannot be hoisted: the code must know a height to decide what to render, and rendering cannot start until the decision is made. In that case the cost is one layout, and the goal is to make sure it is exactly one. Techniques that help:

  • Write, then measure once, then write again — three phases, not 2N.
  • Measure a hidden clone or a memoised value rather than the live element, when the value is stable across frames.
  • Cache measured values and invalidate them on ResizeObserver notifications rather than re-measuring on every access.
  • Use CSS mechanisms for cases that do not need a decision in JavaScript: aspect-ratio for sizing from width, min-height with a fixed line count for placeholder boxes, and flex or grid for distribution.

The last point is the general one: a read exists to inform a decision, and many decisions have a declarative equivalent that the style and layout engines can resolve internally without crossing back into script. The measurements described in Tracing and Attributing Rendering Work identify which reads are worth eliminating; Compositing and Layer Promotion explains why some visual effects can avoid the layout tree entirely.

References

  1. CSS Object Model (CSSOM) View Module — W3C
  2. Resize Observer — W3C
  3. CSS Object Model (CSSOM) — W3C