skip to content

Forced Synchronous Layout

You will learn which property reads force the browser to flush pending style and layout early, and how interleaved reads and writes multiply that cost. Interviewers ask this as the mechanism behind layout thrashing.

on this pageshow

questions

4

This loop runs over 500 elements and is dramatically slower than measuring every element first and then writing every new width. What is the browser doing on each iteration, and why does the split version avoid it? for (const box of document.querySelectorAll('.box')) { const w = box.getBoundingClientRect().width; box.style.width = w + 10 + 'px'; }

level: middleimportance: must knowfreq 60%

answer

  1. ordering problem, not an API problem
  2. each write dirties layout again
  3. the next read forces the flush
  4. N layouts instead of one
  5. measure everything, then mutate everything

basics

~20 s

Each write leaves layout invalidated, and the next iteration's getBoundingClientRect forces the browser to recompute it, so 500 elements cost roughly 500 synchronous layouts. Reading all the widths first and writing afterwards collapses that to a single layout.

solid answer

~50 s

The loop alternates a write and a read, and that ordering defeats the browser's batching. The write to `style.width` is cheap on its own — it just marks layout dirty. But the very next iteration calls `getBoundingClientRect()`, which must return a current value, so the browser flushes style and layout synchronously before answering. Repeat that 500 times and you have paid for roughly 500 layouts inside one task instead of the one the browser would have done before painting. This is layout thrashing. Splitting the loop into a **measure** pass that collects every width into an array and a **mutate** pass that writes them back fixes it: the first read flushes once, the remaining reads ride on clean layout, and none of the writes is ever observed, so they all settle together in the frame's rendering steps.

code

javascript · 9 lines
javascript
const boxes = [...document.querySelectorAll('.box')];

// READ phase - one flush at most, then clean-layout reads
const widths = boxes.map((box) => box.getBoundingClientRect().width);

// WRITE phase - all queued, settled once before the next paint
boxes.forEach((box, i) => {
  box.style.width = `${widths[i] + 10}px`;
});

go deeper

for a junior

Recognise the alternating read-then-write loop as the classic layout-thrashing shape, and know the fix is to read everything before writing anything.

for a middle

Walk the loop iteration by iteration, explaining why the write is deferred and the read is not, and give the layout count for both the interleaved and the split version.

for a senior

Show that invalidation is document-wide, so writing to one element still forces a flush when the next read touches another, and name where this alternation hides in real component code.

for a principal

Own the structural answer: a boundary that separates a measure phase from a mutate phase across components is what stops this reappearing, because individually reasonable helpers compose into thrash.

## Walking the loop one iteration at a time Iteration 1: `getBoundingClientRect()` is called. Layout may already be clean, so this is cheap or costs one flush. Then `box.style.width = …` is assigned — the browser marks layout dirty and moves on. Nothing is recomputed. Iteration 2: `getBoundingClientRect()` is called again, on a different element. Layout is dirty because of the write in iteration 1. The browser cannot return a stale rectangle, so it recalculates style and runs layout **now**, synchronously, before returning. Then the next write dirties it again. Iterations 3 through 500 repeat that. Every iteration after the first pays for a full flush caused by the iteration before it. ## Why the write is cheap and the read is not DOM writes are deliberately lazy. Nothing observes the intermediate state of the document during a task, so the browser defers style recalculation and layout until its rendering steps, once, after the task finishes. A hundred writes cost roughly what one costs. Geometry reads cannot be deferred, because their whole contract is to describe the document as it is right now. So a read acts as a barrier: it drags the deferred work forward into the middle of your script. Write-then-read, repeatedly, is therefore the worst possible ordering — it converts a batched, once-per-frame operation into a per-iteration one. ## The arithmetic - Interleaved: about **N** layouts for N elements, all inside one task. On a page where a layout costs even 1 ms, 500 elements is half a second of frozen main thread. - Split: **one** flush at the first read (if layout was dirty at all), then 499 cheap reads on clean layout, then 500 queued writes settled by one layout before the next paint. The algorithm is identical. Only the ordering changed. ## The fix: separate measure from mutate ```js const boxes = [...document.querySelectorAll('.box')]; const widths = boxes.map((b) => b.getBoundingClientRect().width); // read phase for (const [i, b] of boxes.entries()) { // write phase b.style.width = `${widths[i] + 10}px`; } ``` The rule generalises: **all reads, then all writes**. Any code path that returns to reading after it has written re-enters the flush. ## Fixes that do not work - **Caching the NodeList.** `querySelectorAll` returns a static list and calling it once is tidy, but it is not the cost here. The flushes are. - **Swapping `getBoundingClientRect()` for `offsetWidth`.** Both are layout-derived; both force the flush. The API is not the problem, the order is. - **Reading a different element than you wrote.** Layout dirtiness is not something you can dodge per element: a write anywhere leaves the document's layout invalidated, and the next geometry read anywhere flushes it. - **Wrapping each individual write in its own callback.** Spreading the same alternation across frames replaces one long task with many janky ones, and the total work does not go down. ## Where this hides in real code The explicit loop is the teaching version. In production the alternation is usually spread out and invisible: - a helper that measures an element and then applies a class, called once per item by the caller's loop; - several independent components that each measure and then position themselves during the same event; - a resize or scroll handler that reads a container's size, sets a style, then reads a child's size; - reading `scrollTop` to restore a position after just having inserted rows. The symptom is the same in all of them: one task, many layouts. And the fix is the same too — hoist the measurements to the front, or hand the measurements you already have to the code that writes, so no one has to ask the browser twice. ## What to say in an interview Name the mechanism, not just the pattern: writes are batched, geometry reads force a synchronous flush, and interleaving them multiplies one layout into N. Then give the two-phase fix and note that the invalidation is document-wide, so touching different elements is not a way out.

  • Does the thrashing go away if the loop reads element A but writes element B?
    No. From your code's point of view invalidation is document-wide: a style or DOM write leaves layout dirty for the whole document, and the next geometry read on any element forces the flush. Browsers do optimise how much they recompute internally, but you cannot rely on touching "different" elements to dodge the synchronous layout.
  • What would the same loop cost if it only wrote and never read?
    Essentially one layout. Every assignment would just mark layout dirty, nothing would observe the intermediate state, and the browser would settle all 500 changes once in its rendering steps before the next paint. That is the batching the interleaved version destroys.
  • Is caching the result of querySelectorAll a meaningful optimisation here?
    Not for this problem. `querySelectorAll` returns a static NodeList, and calling it once outside the loop is good style, but selector matching is not where the time goes. The cost is the repeated synchronous layouts, and it stays exactly the same whether the list is cached or not.

saying these in an interview costs you the question

  • Blames querySelectorAll for the slowness
  • Says DOM property access is inherently slow
  • Claims wrapping each write in a callback fixes it
  • Believes reading a different element avoids the flush
  • Assumes the browser batches reads and writes automatically

context

open as a page

In the browser, reading `el.offsetWidth` looks like a plain property access, yet it can be one of the most expensive lines in a frame. What does the browser actually do when that read happens, and when is the same read almost free?

level: middleimportance: must knowfreq 65%

basics

~20 s

Geometry reads such as offsetWidth and getBoundingClientRect must return current values, so the browser first flushes any pending style recalculation and layout synchronously, inside your task. When nothing has been invalidated since the last layout, the same read is nearly free.

open as a page

In the DOM, why does reading `el.style.width` often return an empty string while `el.offsetWidth` returns a number, and which of the two can make the browser do layout work?

level: juniorimportance: should knowfreq 45%

basics

~20 s

el.style.width reads only the element's inline style attribute, so it is empty unless something set it inline. el.offsetWidth reports the element's actual rendered border-box width, so the browser may have to compute pending layout before it can answer.

open as a page

Several floating panels on a page each position themselves in a scroll listener by reading `getBoundingClientRect()` on their anchor element and then writing `style.transform` on the panel, and scrolling stutters badly. How would you restructure that with `requestAnimationFrame` so the page performs at most one layout per frame?

level: seniorimportance: should knowfreq 40%

basics

~20 s

Coalesce the scroll events into one requestAnimationFrame callback per frame with a scheduled flag, and inside that callback read every anchor rectangle first and write every transform afterwards. That yields one layout per frame instead of one per panel per event.

open as a page