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.