React Concurrent Rendering vs Forced Reflow
React’s concurrent rendering — useTransition, automatic batching, time-slicing — defers and batches work to keep the main thread responsive, but it cannot defer a synchronous layout read: the moment a component reads scrollHeight, getBoundingClientRect(), or runs a useLayoutEffect, the browser must flush layout immediately, undoing the benefit. This builds on Animation Performance Patterns, part of Compositing and GPU Acceleration.
What Concurrency Does and Doesn’t Change
Concurrent rendering changes when React commits to the DOM, not what the browser does when geometry is read. Automatic batching coalesces multiple setState calls into one render and one commit, so the browser runs layout once instead of per update. useTransition marks a state update as low-priority so React can interrupt it and yield to input. Both reduce how often React touches the DOM.
But a forced synchronous layout is a browser-level event triggered by reading a geometric property while layout is dirty. React has no special path around it: a useLayoutEffect runs synchronously after commit and before paint, and any getBoundingClientRect() inside it forces layout right then. Concurrency deferred the commit; it cannot defer the flush once you read.
Minimal Reproduction: The Synchronous-Flush Pattern
// ❌ Reading layout inside useLayoutEffect forces a synchronous flush every commit
function AutoGrowList({ items }) {
const ref = useRef(null)
const [height, setHeight] = useState(0)
useLayoutEffect(() => {
// runs synchronously after commit, before paint
const h = ref.current.scrollHeight // forces layout flush right now
setHeight(h) // triggers a SECOND render + commit + layout
}, [items]) // ...on every items change
return <ul ref={ref} style={{ minHeight: height }}>{items.map(renderRow)}</ul>
}
Even wrapped in startTransition, the useLayoutEffect read fires on commit and forces layout; the follow-up setState forces a second layout. Automatic batching does not help because the two layouts are separated by a read.
The Trace Signature
[Commit] React render (low-priority, time-sliced) — concurrency working
└─ Main thread
├─ Commit (DOM mutations)
├─ useLayoutEffect
│ └─ Layout (9.7ms) ← scrollHeight forced flush
├─ Recalculate Style (1.4ms)
└─ Layout (8.9ms) ← setState(height) forced a SECOND layout
Two Layout entries per commit, both with the forced-reflow marker, immediately after a render that React itself scheduled cooperatively — the concurrency win is erased at commit time.
Fix: Defer the Read or Avoid It
Three patterns, in order of preference.
1. Avoid the read. Use CSS that doesn’t need a measured value — min-height from content, height: auto, or container queries — so no JS layout read happens at all.
// ✅ No measurement: let the browser size it; zero forced layout
function AutoGrowList({ items }) {
return <ul style={{ minHeight: 'min-content' }}>{items.map(renderRow)}</ul>
}
2. Defer the read out of the commit. If you must measure, do it in requestAnimationFrame (after paint) rather than useLayoutEffect, and skip the extra setState round-trip by writing the style directly.
// ✅ Read after paint, write directly — one layout, no second render
function AutoGrowList({ items }) {
const ref = useRef(null)
useEffect(() => {
const id = requestAnimationFrame(() => {
const h = ref.current.scrollHeight // layout already settled post-paint
ref.current.style.minHeight = `${h}px` // direct write, no setState re-render
})
return () => cancelAnimationFrame(id)
}, [items])
return <ul ref={ref}>{items.map(renderRow)}</ul>
}
3. Use ResizeObserver / IntersectionObserver. When you need to react to size or visibility, observers deliver measurements off the synchronous render path entirely, so React’s concurrent scheduling stays intact.
// ✅ ResizeObserver: size changes reported without a forced flush in render
useEffect(() => {
const ro = new ResizeObserver(([entry]) => {
onResize(entry.contentRect.height) // batched by the browser, off the commit
})
ro.observe(ref.current)
return () => ro.disconnect()
}, [])
The key rule: keep reads of scrollHeight, offsetTop, and getBoundingClientRect() out of useLayoutEffect and out of the render phase. Pair this with the read/write batching covered in how to batch DOM reads and writes to prevent thrashing, and drive any resulting animation with transforms as in animating transforms without layout thrash.
When useLayoutEffect Is Still Correct
useLayoutEffect exists precisely to measure-then-mutate before paint, avoiding a visible flicker (e.g. positioning a tooltip relative to an anchor). That is a legitimate single forced layout. The anti-pattern is the second layout caused by calling setState with the measured value — set the style imperatively instead, so the measurement and the write share one layout pass.
Verification
// Attribute janky commits to their layout source
new PerformanceObserver((l) => {
for (const e of l.getEntries()) {
if (e.duration > 50) console.warn('LoAF', e.duration, e.scripts.map(s => s.invoker))
}
}).observe({ type: 'long-animation-frame', buffered: true })
| Check | Target |
|---|---|
Forced Layout per commit |
≤ 1 (0 if read avoided) |
setState after a layout read in useLayoutEffect |
none |
| INP during the transition | < 200ms |
| Long Animation Frames at commit | none > 50ms |
A passing trace shows React’s low-priority render yielding to input and at most one Layout at commit. For the underlying browser mechanism and DevTools workflow see Forced Synchronous Layouts, and for the per-frame property cost model see Animation Performance Patterns.
Frequently Asked Questions
Does startTransition stop a forced reflow?
No. startTransition only lowers the priority of a state update so React can interrupt and yield to input during the render phase. Once the update commits, any geometry read in useLayoutEffect — scrollHeight, getBoundingClientRect(), offsetTop — still forces a synchronous layout flush. Scheduling controls when you commit, not what the browser does when you read.
Why does my component run layout twice per commit?
The classic cause is measure-then-setState: you read scrollHeight in useLayoutEffect (forcing layout #1), then call setState with that value, which schedules a second render and commit whose style change forces layout #2. Automatic batching cannot merge them because a geometry read sits between the two writes. Write the measured value to the element’s style imperatively instead, so measurement and mutation share one pass.
Should I read layout in useEffect or useLayoutEffect?
Use useLayoutEffect only when you must measure and mutate before paint to avoid a visible flicker, such as positioning a tooltip against an anchor — that is one legitimate forced layout. For everything else, defer the read into a requestAnimationFrame callback scheduled from useEffect, which runs after paint when layout has already settled, so the read costs nothing extra.
Is ResizeObserver faster than measuring in useLayoutEffect?
For reacting to size changes, yes. ResizeObserver delivers contentRect measurements off the synchronous render path, batched by the browser, so React’s concurrent scheduling stays intact and no flush happens inside your commit. Reserve useLayoutEffect measurement for the pre-paint cases where an observer would arrive one frame too late.
How do I attribute a janky commit to its layout read?
Use the Long Animation Frames API. A PerformanceObserver on long-animation-frame entries reports the scripts array with each frame, and the invoker field names the callback that stalled — pointing you at the useLayoutEffect or effect that forced the reflow. Cross-reference it with the forced-reflow markers in a DevTools performance trace.
Related Guides
- Forced Synchronous Layouts — the browser mechanism behind every reflow this guide defers, with the DevTools workflow to spot it.
- How to Batch DOM Reads and Writes to Prevent Thrashing — the read/write ordering that keeps a single layout pass per frame.
- Animating Transforms Without Layout Thrash — drive the resulting animation on the compositor instead of forcing layout.
- Animation Performance Patterns — the per-frame property cost model this page builds on.