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. It is part of Layout and Paint Optimization.
The contrast below is the whole point. Interleaving each read with a write forces one layout flush per iteration β N reads produce N flushes. Hoisting all reads ahead of all writes collapses that to a single flush, regardless of element count.
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. The full marker-reading procedure lives in Finding layout thrashing in DevTools. - 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
The diagram below maps that trace to the DevTools capture path: a throttled recording surfaces the Layout spike, whose call stack unwinds to the exact geometry read that triggered the 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
})
Structurally, the two-phase pattern splits a single task into a read window that ends with one flush and a write window deferred to the next animation frame, where the invalidation is coalesced before paint.
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. Reactive watchers that read geometry on every dependency tick are a recurring source of forced synchronous layouts β see Vue reactivity and layout thrashing.
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
Each ecosystem exposes a different hook for landing reads after the frameworkβs own DOM update has settled, so a measurement observes committed geometry rather than forcing a flush mid-render.
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).
On the timeline the two performance.measure bands sit on opposite sides of the frame boundary β the read phase entirely inside the current task and the write phase in the following animation frame β with the whole sequence fitting under the 16.6ms budget.
| 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% |
Frequently Asked Questions
Why does a single geometry read after a write force a full layout?
The browser keeps style and layout invalidated but unresolved until the end of a task. A read of offsetHeight, getBoundingClientRect(), or scrollTop needs an accurate answer, so it forces the pending invalidation to resolve immediately β a synchronous forced layout. One write plus one read is enough to trigger it; the cost scales with how many times you interleave the pair.
Does requestAnimationFrame alone fix layout thrashing?
No. requestAnimationFrame only moves work to the next frame; if you still interleave reads and writes inside the callback, each read flushes layout exactly as before. The fix is ordering β all reads first, then all writes β with rAF used to defer the write phase, not to replace the batching.
How is ResizeObserver different from reading offsetWidth in a resize listener?
ResizeObserver callbacks fire after layout has already completed, so entry.contentRect reports committed geometry without forcing an extra flush. Reading offsetWidth inside a resize or scroll listener runs before layout has settled for your mutations, forcing a synchronous recalculation on every event.
What does a thrashing pattern look like in a DevTools trace?
It appears as repeated short Layout events, each tagged with a red Forced Reflow marker, whose call stack ends in a geometry read line. Total JavaScript time can look small while layout time dominates the frame. The full marker-reading procedure is in Finding layout thrashing in DevTools.
Related Guides
- Reflow and Repaint Triggers β the parent guide covering every operation that invalidates layout or paint.
- Finding layout thrashing in DevTools β the marker-by-marker procedure for locating forced reflows in a trace.
- Forced Synchronous Layouts β the mechanism behind why an early read flushes deferred layout work.
- Vue reactivity and layout thrashing β how watchers that read geometry per tick reintroduce thrashing.