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.

What concurrency defers versus what a read forces Scheduling controls when React commits, but reading geometry after commit forces a synchronous layout the scheduler cannot move. React controls scheduling — the browser controls the flush Concurrency CAN defer setState (low pri) Batch + time-slice Commit to DOM Concurrency CANNOT defer read scrollHeight Forced layout NOW The read while layout is dirty triggers a synchronous flush the scheduler has no path around.

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 double-layout sequence inside one commit A read in useLayoutEffect forces the first layout, then setState with the measured value forces a second layout in the same commit. One items change, two forced layouts Commit DOM read scrollHeight useLayoutEffect Layout #1 forced flush setState(height) Layout #2 re-render Batching cannot merge the two: a geometry read sits between them, so each dirties layout again.

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.

Main-thread timeline for a janky commit A cooperative low-priority render is followed by two forced layout blocks at commit, marking the frame as janky. Main-thread timeline, left to right Render time-sliced, yields Commit Layout 9.7ms scrollHeight Recalc Style 1.4ms Layout 8.9ms setState reflow concurrency working concurrency win erased at commit

Fix: Defer the Read or Avoid It

Three patterns, in order of preference.

Decision tree for eliminating the forced flush Decide whether a measurement is needed, then route to CSS-only sizing, a post-paint read, or an observer. Do you truly need a measured value? No Yes CSS sizing: min-content / auto React to a change, or measure once? once on change read in rAF, write style after paint, no setState ResizeObserver

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.

Imperative write versus setState after a measurement Writing the measured value to style imperatively keeps one layout pass, while calling setState forces a second render and layout. Correct: one layout pass measure anchor el.style.top = ... shares the flush one forced Layout, no re-render Anti-pattern: two passes measure anchor setState re-render + Layout second forced Layout on the commit

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 useLayoutEffectscrollHeight, 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.