Forced Synchronous Layouts

Forced synchronous layout — also called layout thrashing or forced reflow — is the frame-budget killer that happens when JavaScript reads a geometry property after mutating the DOM in the same task. The browser normally defers style and layout resolution to a single batched step at the end of the task; a geometry read with pending invalidations forces it to compute layout right now, synchronously, before the read can return. This is part of Layout and Paint Optimization, and it sits alongside the broader set of Reflow and Repaint Triggers.

The cost compounds inside loops. A read-write-read-write sequence over 200 list items produces 200 separate synchronous layouts where a single batched flush would have sufficed — turning a sub-millisecond task into a 12ms+ frame drop.

Interleaved reads and writes force N layouts versus one batched flush The top lane shows reads and writes interleaved, each read forcing a synchronous layout. The bottom lane batches all reads then all writes into a single layout flush. Interleaved: N layouts Batched: 1 layout write read write read write read each read = forced flush read read write write 1 flush

This topic covers what triggers the synchronous flush, which property reads are dangerous, and how read/write batching restores a single layout per frame. For the canonical batching recipe, see How to batch DOM reads and writes to prevent thrashing.

The Read-After-Write Pattern

The browser maintains a “dirty” flag on the layout tree. A DOM write — adding a class, setting style.width, inserting a node — sets that flag without doing any work, because layout is deferred. The moment JavaScript reads a property whose value depends on up-to-date geometry, the engine has no choice: it must run style recalc and layout synchronously to produce a correct answer, then return control to your script.

The layout dirty-flag state cycle A DOM write moves the layout tree from clean to dirty; a geometry read forces a synchronous layout that returns the tree to clean. Clean cached reads < 0.1ms Dirty layout invalidated Forced layout synchronous 2–12ms write geometry read flush completes → tree clean again
// ❌ Read-after-write: one forced synchronous layout per iteration
for (const card of cards) {
  card.classList.add('expanded')      // write: marks layout tree dirty
  const h = card.offsetHeight         // read: forces synchronous layout flush
  card.style.setProperty('--h', `${h}px`) // write: dirties again for next loop
}

Each iteration writes, reads (flushing), then writes again — so every card pays for a full layout pass. The fix is to split the loop into a read phase and a write phase so the dirty flag is set once and cleared once.

// ✅ Batched: all reads (one flush), then all writes (one invalidation)
const heights = cards.map((card) => {
  card.classList.add('expanded')   // writes only — no read between them
  return card                      // defer measurement
})
const measured = heights.map((card) => card.offsetHeight) // one flush for all reads
measured.forEach((h, i) => cards[i].style.setProperty('--h', `${h}px`))

Properties That Force Layout

Any property whose value cannot be known without resolved geometry forces a flush when the layout tree is dirty. The exact set is engine-defined, but these are the reads that bite in practice:

  • Box metrics: offsetTop, offsetLeft, offsetWidth, offsetHeight, offsetParent
  • Client box: clientTop, clientLeft, clientWidth, clientHeight
  • Scroll metrics: scrollTop, scrollLeft, scrollWidth, scrollHeight, scrollIntoView(), scrollBy()
  • Rects: getBoundingClientRect(), getClientRects()
  • Resolved style: getComputedStyle() for any layout-dependent property (width, height, margins, top/left)
  • Range and focus: Range.getBoundingClientRect(), el.focus() with scroll, el.innerText (forces layout to determine rendered text)
  • Viewport: window.getComputedStyle, scrollX/scrollY read after a write that affects document height

Reading any of these when nothing is dirty is cheap — the engine returns a cached value. The danger is only the read-after-write ordering.

Whether a geometry read forces layout depends on the dirty flag A decision tree: a geometry read on a dirty layout tree forces a synchronous flush, while the same read on a clean tree returns a cached value. Geometry read offsetHeight, getBoundingClientRect() Layout tree dirty? Forced synchronous layout style recalc + layout, 2–12ms Cached value no work, < 0.1ms yes (pending write) no (clean)
Phase Triggering condition Typical cost Budget risk
Style recalc Pending class/style writes flushed by a read 0.5–3ms Medium
Forced layout Geometry read with dirty layout tree 2–12ms High
Per-iteration thrash Read-after-write inside an N-item loop N × layout cost Critical
Cached read Geometry read with clean layout tree < 0.1ms None

Reading the Trace

In the Chrome DevTools Performance panel, a forced synchronous layout appears as a purple Layout event with a red triangle and a Forced reflow warning naming the total time blocked. The call tree attributes the layout to the exact JavaScript line that read geometry.

[Main Thread] Task (18.9ms)  — exceeds 16.6ms budget, DROPPED
└─ Function Call updateCards (16.2ms)
   └─ Layout (12.4ms)  ⚠ Forced reflow — likely performance bottleneck
      └─ Recalculate Style (2.1ms)
         └─ HTMLElement.offsetHeight  ← forced synchronous flush
Frame Budget: 16.6ms | Actual: 18.9ms
A forced reflow in the Performance panel flame chart Nested flame bars show a task exceeding the frame budget because a forced Layout event nests under a geometry read. Task — 18.9ms (exceeds budget, frame dropped) Function Call updateCards — 16.2ms ⚠ Layout (forced reflow) — 12.4ms Recalculate Style — 2.1ms 16.6ms budget offsetHeight read triggers the forced Layout

The step-by-step procedure for spotting these markers, attributing them through the call tree, and confirming the fix lives in Finding Layout Thrashing in DevTools.

Framework Reactivity Interactions

Modern frameworks already batch DOM writes for you — Vue’s watcher flush queue and React’s commit phase both coalesce mutations. Thrashing reappears when your own code reads geometry inside a reactive callback before the framework’s flush has run, or measures a node synchronously and then writes back. The Vue-specific patterns — nextTick, watcher flush timing, and where manual reads still force reflow — are covered in Vue Reactivity and Layout Thrashing.

A manual read jumps ahead of the framework flush queue The top lane lets the framework batch writes into one commit layout; the bottom lane reads geometry inside the callback, forcing an extra layout before the commit. Let the framework flush: 1 layout state change queued write commit → 1 layout Read inside the callback: 2 layouts state change offsetHeight → flush commit → 2nd layout

The Batching Cure

The universal cure has three forms, in order of preference:

  1. Split read and write phases. Collect every measurement first, then apply every mutation. One flush, one invalidation, regardless of element count.
  2. Defer writes to requestAnimationFrame. Reads happen synchronously; writes run at the start of the next frame, after the browser’s own layout pass.
  3. Replace polling with observers. ResizeObserver and IntersectionObserver deliver geometry after layout has settled, so their callbacks never force a flush.
Three cures ranked by preference From most preferred to fallback: split read and write phases, defer writes to requestAnimationFrame, then replace polling with observers. most preferred → fallback 1. Split phases read all, then write all 2. Defer to rAF writes next frame 3. Observers Resize / Intersection all three collapse N forced layouts into a single flush per frame
// ✅ ResizeObserver reports geometry post-layout — no forced flush
const ro = new ResizeObserver((entries) => {
  for (const entry of entries) {
    const { width, height } = entry.contentRect // already resolved
    entry.target.style.setProperty('--w', `${width}px`)
  }
})
ro.observe(panel)

Where CSS can do the job, prefer it: a contain: layout boundary keeps a component’s geometry changes from propagating outward, shrinking the layout scope a forced flush has to recompute. That structural approach is detailed in CSS Containment Strategies.

Validation

After refactoring, re-record under 6× CPU throttling and confirm the forced-reflow markers are gone.

Before and after batching: layout time and forced-reflow count Bar comparison showing layout event duration and forced-reflow markers dropping after the read and write phases are split. Layout event duration Forced-reflow markers 12.4ms before 3.5ms after N markers before 0 after
// Long Animation Frames surface tasks that blocked rendering
new PerformanceObserver((list) => {
  for (const frame of list.getEntries()) {
    if (frame.duration > 50) console.warn('LoAF', frame.duration, frame.scripts)
  }
}).observe({ type: 'long-animation-frame', buffered: true })
Metric Target How measured
Forced reflow markers per interaction 0 Performance panel, Layout events
Layout event duration < 4ms Trace call tree
INP < 200ms Event Timing API / field RUM
LoAF duration < 50ms long-animation-frame observer

The Read-After-Write Mechanism

A forced synchronous layout happens when you read a layout-dependent property after having written to the DOM in the same frame. Normally the engine batches style and layout changes and flushes them once, at the frame boundary, so many writes cost one layout. But a read of offsetHeight, getBoundingClientRect(), scrollTop, or any property whose value depends on final geometry cannot be answered from the pending, un-flushed state — so the engine is forced to run layout immediately, mid-task, to return a correct value. That synchronous flush is the “forced reflow.” One is a minor inefficiency; the trouble comes when reads and writes interleave in a loop, because each read re-flushes the layout the previous write invalidated.

That interleaving is the layout-thrashing multiplier. A loop that reads an element’s height and then writes a new height, item by item over a list, forces one layout per iteration — turning what should be a single layout per frame into dozens, and turning a smooth interaction into a visible stall on a mid-tier device. The cost is entirely structural: the same reads and writes, reordered so all reads happen first and all writes second, cost exactly one layout. Nothing about the work changes except its ordering, which is what makes this one of the highest-leverage fixes in front-end performance.

// ❌ interleaved — one forced layout per iteration (thrash)
items.forEach((el) => {
  const w = el.offsetWidth       // read → flush pending layout
  el.style.width = (w + 10) + 'px' // write → invalidate layout
})

// ✅ batched — one layout for all reads, one for all writes
const widths = items.map((el) => el.offsetWidth) // all reads, one flush
items.forEach((el, i) => { el.style.width = (widths[i] + 10) + 'px' })

Batching Reads and Writes in Practice

The fix generalises into a simple architectural rule: separate the measure phase from the mutate phase. Gather every geometry value you need at the top of the frame, before touching the DOM, then apply every write together. Libraries like FastDOM formalise this by scheduling reads and writes into separate requestAnimationFrame phases, but the discipline matters more than the tool — even a hand-rolled “read all, then write all” ordering eliminates the thrash. When a third-party widget or a framework lifecycle hook forces an interleave you cannot control, isolating that widget behind contain: layout at least bounds the forced relayout to its own subtree instead of the whole document, as covered in CSS containment strategies.

Confirming the fix is a matter of reading a trace. In the DevTools Performance panel a forced reflow appears as a Layout event nested inside a JavaScript call frame, flagged with a red-cornered “forced reflow” warning; a healthy frame shows a single Layout at the end, outside your script. The signature to hunt for is the sawtooth of alternating Layout and Recalculate Style events within one task — the fingerprint of interleaved reads and writes. After batching, that sawtooth should collapse into one trailing layout, and finding a forced reflow fast is exactly what finding layout thrashing in DevTools walks through.

The Properties That Force a Flush

Part of avoiding forced layouts is simply knowing which reads trigger them, because any property whose value depends on final geometry cannot be answered without a layout if anything is dirty. The recurring offenders are the offset family (offsetTop, offsetLeft, offsetWidth, offsetHeight), the client family (clientWidth, clientHeight), the scroll family (scrollTop, scrollLeft, scrollWidth, scrollHeight), getBoundingClientRect(), getClientRects(), scrollIntoView(), and getComputedStyle() when read for a layout-affecting property. Reading any of these after a write in the same frame forces the flush. Committing the list to memory is worthwhile because a single stray offsetHeight inside a loop is the difference between one layout per frame and one per iteration.

None of these reads are forbidden — the problem is only their timing. Read them all you like at the top of a frame, before any write, and they resolve against a single clean layout at no extra cost. The failure is exclusively the interleave: read, write, read, write, each read re-flushing what the previous write invalidated. When a framework hook or a third-party script forces the interleave for you, you cannot always reorder its code, but you can bound the damage by isolating that component behind contain: layout so the forced relayout stays within its subtree. Knowing the offending properties and the batching rule together covers essentially every forced-reflow bug you will meet in practice. The one addition worth making is a habit of suspicion around library code that measures the DOM: animation helpers, autosizing text inputs, virtualised lists, and tooltip positioners all read geometry, and if they do so inside a loop or a per-item callback they can reintroduce the thrash your own code avoided. When a trace shows forced reflows you cannot trace to your own reads, a third-party measurement is the usual source, and the containment-boundary mitigation is what limits its damage without requiring you to patch the library.

Frequently Asked Questions

What is the difference between a forced synchronous layout and a normal layout?

A normal layout runs once per frame, batched by the browser at the end of the task after all your writes land. A forced synchronous layout happens mid-task: a geometry read on a dirty layout tree makes the engine stop and compute layout immediately so the read can return a correct value. The work is identical; the difference is that a forced flush can run many times per frame instead of once, which is what turns a sub-millisecond task into a dropped frame.

Does reading offsetHeight always cause a forced reflow?

No. Reading offsetHeight only forces layout when the layout tree is dirty — that is, when a DOM write is pending since the last flush. If nothing has been written, the engine returns a cached value in under 0.1ms. The rule is about ordering: a geometry read is only expensive when it follows a write in the same task.

Why does the cost scale with the number of loop iterations?

Each iteration that writes then reads re-dirties the layout tree and then forces a flush to satisfy the read. With N items you pay for N full layout passes instead of one. Splitting the loop into a read phase and a write phase dirties the tree once and flushes once, so the cost stops scaling with element count.

Do CSS transforms avoid forced synchronous layout?

Transforms and opacity are handled on the compositor and do not dirty the layout tree, so animating them does not force a flush. The trap is reading a layout property such as getBoundingClientRect() after a transform when other writes are pending — that read still flushes. Keep animation on the compositor and keep measurement out of the write path.

Can requestAnimationFrame eliminate layout thrashing?

It helps when used as a write scheduler: read geometry synchronously, then defer every mutation into a requestAnimationFrame callback so writes run at the start of the next frame after the browser’s own layout pass. It does not help if you read geometry inside the rAF callback after writing in the same callback — that recreates the read-after-write flush. The reliable cure is still separating reads from writes.