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.

Interleaved reads and writes versus batched Interleaving a read after each write forces a layout flush every iteration, while batching all reads before all writes triggers a single flush. Interleaved β€” N flushes write read flush write read flush … Batched β€” 1 flush read read flush write write

Diagnostic Workflow

  1. Capture a baseline trace: DevTools β†’ Performance β†’ enable Disable cache β†’ apply 6x CPU throttling (mid-tier device simulation) β†’ Record β†’ reproduce the interaction β†’ Stop.
  2. Find layout spikes: Main thread timeline β†’ filter by Layout. Look for events exceeding 4ms.
  3. Read the call stack: Select the offending Layout event. 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.
  4. 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.

Diagnostic path from trace to offending line A throttled recording surfaces a Layout spike whose call stack unwinds to the geometry read that forced the flush. Record (6x CPU) reproduce interaction Filter Layout find events > 4ms Expand stack Bottom-Up tab Offending line read after write Layout spike (12.4ms) Recalculate Style Update Layout Tree getBoundingClientRect() β€” forced 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.

Two-phase read then write architecture All geometry reads run in one measure phase ending in a single flush, and all writes are deferred to the next frame as one coalesced invalidation. Phase 1 β€” measure (this task) read offsetHeight read rect.width single layout flush Phase 2 β€” mutate (next rAF) write height write width one coalesced invalidation rAF

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.

Framework hooks for post-commit geometry reads React useLayoutEffect, Vue nextTick, and vanilla ResizeObserver each place geometry reads after the DOM commit so no extra flush is forced. DOM commit React useLayoutEffect read Vue 3 await nextTick() read Vanilla ResizeObserver read

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.

Read and write phase user-timing bands across a frame The read-phase band sits in the current task and the write-phase band in the next animation frame, both inside the 16.6ms budget. 0ms 16.6ms frame boundary (rAF) read-phase measure write-phase measure
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.