In a React list of 200 rows, every row runs a useLayoutEffect that reads its own offsetHeight and then writes an inline height style onto the same node. Mounting the list is visibly janky. What is the browser doing, and how would you restructure the measurement?
answer
- reads and writes are interleaved
- the browser must answer with fresh geometry
- one dirty write invalidates the next read
- all reads first, then all writes
- layout effects sit before the paint
basics
~20 sEach row's style write invalidates layout, and the next row's offsetHeight read forces the browser to recompute it synchronously — 200 forced layouts, all inside the commit before the browser is allowed to paint. Batch the work: read every row first, then apply every write.
solid answer
~50 sReading `offsetHeight` is a layout-dependent property, so the browser must return an up-to-date value; if a style write has invalidated layout since the last computation, the read forces a synchronous recalculation right there. Interleaving one read and one write per row turns 200 rows into 200 forced layouts — the classic layout thrash — and because these are layout effects, all of it lands between the DOM mutation and the paint, so the frame is held hostage for the whole thing. The restructure is to separate the phases: one effect on the parent that reads all 200 heights into an array first, then writes all 200 styles, which costs one layout instead of 200. Beyond that I would question the measurement itself: a `ResizeObserver` hands you sizes the browser already computed, and quite often the height can be expressed in CSS so nothing needs measuring.
go deeper
Know that reading a size from the DOM is not a free property access: it can force the browser to recompute layout, so doing it repeatedly in a loop is far more expensive than it looks.
Be able to explain the invalidation cycle — a style write dirties layout, the next layout-dependent read forces a synchronous recomputation — and show the read-then-write restructure that collapses N layouts into one.
Diagnose before fixing: profile the mount, identify alternating style/layout entries attributed to your effect, and choose between batching, an observer, a CSS solution and virtualization based on what the trace actually shows.
Set the boundary for the codebase: define what may run in commit-phase measurement at all, cap the cost by making it proportional to visible rows, and treat per-item measurement effects as an architectural smell that belongs at the container level or nowhere.
## What "forced synchronous layout" means Browsers avoid recomputing layout eagerly. When you change a style, the element is marked dirty and the actual geometry calculation is deferred to just before the next paint, so many changes can be coalesced into one layout pass. That optimisation only holds while nobody asks a question that requires the answer now. Some DOM properties are layout-dependent: `offsetWidth`, `offsetHeight`, `offsetTop`, `clientHeight`, `scrollHeight`, `getBoundingClientRect()`, `getComputedStyle()` for layout-affecting properties. Reading one of these must return a correct value, so if layout is dirty the browser stops, recomputes layout synchronously, and then answers. Chrome DevTools labels this a forced reflow and warns when a single one takes a long time. A forced layout on its own is not a bug. The pathology is the *loop*: ```js for (const row of rows) { const h = row.offsetHeight; // forces layout, because the previous write dirtied it row.style.height = `${h}px`; // dirties layout again for the next iteration } ``` Every iteration invalidates what the next iteration demands. N rows cost N full layout passes over the document instead of one. This is layout thrashing, and it is why the symptom scales with list size — 20 rows feels fine, 200 rows stutters. ## Why React's per-row effect produces exactly that loop React runs layout effects during the commit, child-first, one component at a time. A `useLayoutEffect` defined inside the row component therefore executes 200 times in sequence within a single synchronous block, and each execution does one read and one write against a document the previous execution just dirtied. The code looks local and innocent in the row file; the interleaving only exists at the list level, which is why this bug survives review. The second aggravating factor is *which* effect it is. Layout effects sit between DOM mutation and paint by design. Whatever they cost is added directly to the frame the user is waiting on — the browser cannot paint a partial result and finish later. Two hundred forced layouts inside `useEffect` would still be wasteful, but the page would at least have painted first. ## Restructuring: separate the read phase from the write phase The mechanical fix is to make all reads happen while layout is clean, then all writes: ```jsx useLayoutEffect(() => { const rows = Array.from(containerRef.current.querySelectorAll('[data-row]')); const heights = rows.map((row) => row.offsetHeight); // reads only — one layout rows.forEach((row, i) => { // writes only row.style.height = `${heights[i]}px`; }); }, [items]); ``` One layout computation serves all 200 reads, because nothing dirties layout in between. This is the read-then-write discipline, and it is the single highest-value habit in measurement code. Note that the effect has moved to the parent: batching is only possible from a scope that can see all the nodes, which is also why a shared measurement hook usually lives on the container rather than the item. ## Better than batching: not measuring Batching turns 200 forced layouts into one. Removing the measurement removes all of them. - **Let the browser report sizes.** A `ResizeObserver` delivers `entry.contentRect` / `entry.borderBoxSize` from measurements the browser already performed, so reading them forces nothing. If you need sizes continuously, an observer is strictly better than repeated manual reads. - **Express it in CSS.** A great deal of measured-then-applied geometry — equal heights, filling remaining space, sticky offsets — is what flexbox, grid and `position: sticky` do natively, with no JavaScript on the frame path. - **Downgrade the urgency.** If a one-frame difference is acceptable, `useEffect` gets the work off the critical path so the browser paints first. - **Do not measure what is not on screen.** For long lists, virtualization means you only ever measure the visible window, which caps the cost regardless of dataset size. ## Confirming it rather than guessing Record a performance profile while mounting the list. Thrashing has a recognisable signature: a long scripting block containing many alternating Recalculate Style / Layout entries, attributed to your effect's stack, sitting before the paint. Chrome also logs a violation warning for a long forced reflow. That evidence distinguishes this from the other common causes of mount jank — too many components, an expensive render, or an unmemoized parent — which need entirely different fixes. ## The rule to carry away Inside any commit-phase measurement pass: batch reads, then batch writes, keep the pass proportional to what is visible, and prefer a measurement the browser volunteers over one you force.
- Why is this worse in useLayoutEffect than the same code in useEffect?A layout effect runs between the DOM mutation and the paint, so every forced layout is added to the frame the browser is trying to finish — the user waits on all of it before seeing anything. A passive effect runs after the paint, so the same waste delays subsequent frames instead of blocking the first one. The work is equally wasteful either way; only who pays for it changes.
- How would you confirm layout thrashing rather than assume it?Record a performance profile while the list mounts and look for a long scripting block containing many alternating style/layout recalculations attributed to the effect's call stack, sitting before the paint. Chrome also logs a forced-reflow violation when a single one is slow. Without that evidence, mount jank is just as likely to be an expensive render or too many components, which have completely different fixes.
- Does moving the reads into requestAnimationFrame fix the thrashing?No. It moves the work to a different moment but keeps the interleaving, so the same number of forced layouts still happens — now in a later frame, and for a measure-then-position case it also reintroduces the visible wrong frame you were avoiding. Ordering the phases is what removes the cost; scheduling only changes which frame absorbs it.
saying these in an interview costs you the question
- Reading offsetHeight is free, it is just a property
- Two hundred small reads cannot matter
- Wrapping it in requestAnimationFrame removes the cost
- React batches DOM reads and writes for you
- Any jank on mount means too many re-renders