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.
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.
// ❌ 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/scrollYread 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.
| 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
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.
The Batching Cure
The universal cure has three forms, in order of preference:
- Split read and write phases. Collect every measurement first, then apply every mutation. One flush, one invalidation, regardless of element count.
- Defer writes to
requestAnimationFrame. Reads happen synchronously; writes run at the start of the next frame, after the browser’s own layout pass. - Replace polling with observers.
ResizeObserverandIntersectionObserverdeliver geometry after layout has settled, so their callbacks never force a flush.
// ✅ 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.
// 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 |