How to Batch DOM Reads and Writes to Prevent Thrashing
Layout thrashing is a frame-drop pattern caused by synchronous interleaving of geometry reads and DOM writes within the same JavaScript task. The browser defers style and layout resolution until the end of a task; a geometry read after a write forces it to flush that deferred work immediately. In the Chrome DevTools Performance panel, this shows as Layout events with red Forced Reflow markers, often exceeding 10ms, despite low overall JavaScript execution time.
The root cause is reading offsetHeight, getBoundingClientRect(), getComputedStyle(), scrollTop, or similar properties immediately after mutating the DOM. Each read-write pair in a loop produces one forced synchronous layout per iteration. For a list of 200 items, that is 200 synchronous layouts where one would suffice.
This pattern is a primary contributor to Reflow and Repaint Triggers and violates the 16.6ms frame budget.
Diagnostic Workflow
- Capture a baseline trace: DevTools → Performance → enable Disable cache → apply 6x CPU throttling (mid-tier device simulation) → Record → reproduce the interaction → Stop.
- Find layout spikes: Main thread timeline → filter by
Layout. Look for events exceeding 4ms. - Read the call stack: Select the offending
Layoutevent. In the Summary or Bottom-Up tab, expand the JS call stack. Find the exact line where a geometry read follows a mutation. - Verify with overlays: Rendering tab → enable Paint flashing and Layout shift regions. Synchronous invalidation boundaries overlapping with scroll listeners or animation loops confirm thrashing.
A thrashing trace typically resolves to:
[Layout] (12.4ms)
└─ [Recalculate Style]
└─ [Update Layout Tree]
└─ [Script] element.getBoundingClientRect() ← forced synchronous flush
Batching Architecture
The fix is to collect all geometry reads first, then apply all writes. This produces one layout flush for the reads and one layout invalidation for the writes, regardless of how many elements are being processed.
// Phase 1: reads — executed synchronously, causes one layout flush
// Phase 2: writes — deferred to the next frame via rAF, causes one invalidation
function batchReadWrite(elements) {
// Collect all reads before touching the DOM
const measurements = elements.map((el) => ({
el,
height: el.offsetHeight,
width: el.getBoundingClientRect().width,
}))
// Apply all writes in the next animation frame
requestAnimationFrame(() => {
measurements.forEach(({ el, height, width }) => {
el.style.height = `${height}px`
el.style.width = `${width}px`
})
})
}
If reads and writes must happen within the same rAF callback (same frame), perform all reads first, then all writes — never interleave them:
requestAnimationFrame(() => {
// All reads first
const h1 = el1.offsetHeight
const h2 = el2.offsetHeight
// All writes after
el1.style.height = `${h1 + 10}px`
el2.style.height = `${h2 + 10}px`
// One layout invalidation; processed at the end of this rAF callback
})
Framework-Specific Guidance
React: Avoid direct DOM access in render methods entirely. Use useLayoutEffect for synchronous reads that must happen before paint (measuring a DOM node for animation setup), and useEffect for reads that can happen after paint. For bulk mutations, startTransition batches state updates into a lower-priority render.
Vue 3: nextTick() defers DOM access until after Vue’s next DOM update cycle. Wrap geometry reads that depend on freshly-updated DOM in await nextTick() within async setup() or lifecycle hooks.
Vanilla / framework-agnostic: Use ResizeObserver instead of reading offsetWidth/offsetHeight in resize listeners. ResizeObserver fires after layout has completed (not before), so callbacks always observe the current geometry without forcing an additional layout flush:
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect
// width and height are current — no forced layout
updateLayout(entry.target, width, height)
}
})
observer.observe(container)
For bulk DOM insertions, use DocumentFragment to batch the insert into a single layout invalidation:
const fragment = document.createDocumentFragment()
items.forEach((item) => {
const li = document.createElement('li')
li.textContent = item.label
fragment.appendChild(li)
})
list.appendChild(fragment) // one DOM mutation, one layout invalidation
Measuring Improvement
After refactoring, re-run the trace under the same CPU throttling conditions.
// Instrument read and write phases to verify separation
performance.mark('read-start')
// Phase 1: reads
performance.mark('read-end')
performance.measure('read-phase', 'read-start', 'read-end')
requestAnimationFrame(() => {
performance.mark('write-start')
// Phase 2: writes
performance.mark('write-end')
performance.measure('write-phase', 'write-start', 'write-end')
})
The resulting PerformanceMeasure entries appear in the DevTools Timeline, making it easy to confirm that reads and writes are separated and that the total JS budget stays within the ~12ms target (leaving 4.6ms for browser overhead and rasterization).
| Metric | Target |
|---|---|
Layout event duration per frame |
< 4ms (no Forced Reflow markers) |
| Frame rate | 60fps stable, 0 dropped frames during interaction |
| INP | < 200ms |
| TBT reduction vs. baseline | ≥ 40% |