Minimize costly DOM read/write operations
Batch DOM reads and writes separately to avoid layout thrashing — the performance problem caused by alternating between reading and writing layout properties.
- Never interleave DOM reads (offsetHeight, getBoundingClientRect) with DOM writes (style changes)
- Batch all reads first, then all writes
- Use requestAnimationFrame for visual updates
- Use DocumentFragment or innerHTML for bulk DOM insertion
- Avoid per-frame React state updates across many visible components when a simpler CSS or reduced-frequency approach would work
- Use passive listeners for scroll, touch, and wheel when you do not call preventDefault
Rule Details
The browser maintains a representation of your page layout. When you change the DOM, it marks the layout as "dirty". When you then read a layout property, it must synchronously recompute layout before it can give you a value — this is forced synchronous layout.
Code Example
// ❌ Bad: read-write-read-write interleaved — forces reflow on each iteration
const items = document.querySelectorAll('.item')
items.forEach(item => {
const height = item.offsetHeight // READ — forces reflow
item.style.height = height * 2 + 'px' // WRITE — invalidates layout
// Next iteration: READ again forces another reflow!
})
// ✅ Good: all reads first, then all writes
const items = document.querySelectorAll('.item')
const heights = Array.from(items).map(item => item.offsetHeight) // All READs
items.forEach((item, i) => {
item.style.height = heights[i] * 2 + 'px' // All WRITEs
})Why It Matters
Every time you read a layout property after making a DOM change, the browser must synchronously recalculate layout (reflow) before returning the value. Doing this in a loop — even with just 10 elements — can freeze the page for hundreds of milliseconds. This is called layout thrashing and is one of the most common causes of janky animations and slow renders.
Use requestAnimationFrame for Visual Updates
// ❌ Bad: visual updates outside of animation frame can cause visual glitches
function animateItems() {
items.forEach(item => {
item.style.transform = `translateX(${getNextPosition()}px)`
})
setTimeout(animateItems, 16) // Not synchronized with display refresh
}
// ✅ Good: use rAF to sync with the browser's paint cycle
function animateItems() {
requestAnimationFrame(() => {
items.forEach(item => {
item.style.transform = `translateX(${getNextPosition()}px)`
})
animateItems()
})
}Bulk DOM Insertion
// ❌ Bad: appending items one by one causes a reflow per item
const list = document.getElementById('list')
for (let i = 0; i < 1000; i++) {
const item = document.createElement('li')
item.textContent = `Item ${i}`
list.appendChild(item) // reflow potentially triggered each time
}
// ✅ Good: use DocumentFragment — only one DOM insertion
const fragment = document.createDocumentFragment()
for (let i = 0; i < 1000; i++) {
const item = document.createElement('li')
item.textContent = `Item ${i}`
fragment.appendChild(item) // no reflow — fragment is off-DOM
}
list.appendChild(fragment) // one reflowCSS Class Toggling Over Inline Styles
// ❌ Bad: multiple style assignments, each can trigger reflow
element.style.width = '200px'
element.style.height = '100px'
element.style.backgroundColor = '#f00'
element.style.border = '2px solid #000'
// ✅ Good: one class change, one reflow
element.classList.add('expanded')
// Define in CSS:
// .expanded { width: 200px; height: 100px; background: #f00; border: 2px solid #000; }CSS contain for Isolation
/* Tell the browser this element's internals don't affect the outside layout */
.widget {
contain: layout style;
}Passive Event Listeners
Scroll and touch handlers can delay browser scrolling if the browser must wait to learn whether your code will call preventDefault(). Mark handlers passive when they only observe state:
// ❌ Bad: browser must assume scrolling might be blocked
window.addEventListener('scroll', onScroll)
// ✅ Good: tells the browser this handler will not cancel scrolling
window.addEventListener('scroll', onScroll, { passive: true })
window.addEventListener('wheel', onWheelTelemetry, { passive: true })
window.addEventListener('touchmove', onTouchTelemetry, { passive: true })Do not mark a listener passive if the handler intentionally calls preventDefault(), such as for a custom gesture recognizer.
Verification
- Verify the behavior in the browser after the code change, not only in static analysis.
- Inspect DevTools Network or Performance panels when the rule affects loading or execution order.
- Test the primary user flow and one edge case triggered by the changed script path.
- Confirm the code still behaves correctly when the feature is delayed, lazy-loaded, or fails.
- Check DevTools for scroll-blocking listener warnings and confirm passive handlers are used where cancellation is unnecessary.
Use with AI
Copy these prompts to use with your AI assistant, or install the MCP server to use directly from Claude, Cursor, or Windsurf.
Check
Verify implementation
Identify any layout thrashing in this code — places where DOM reads and writes are interleaved in loops. Also flag scroll, touch, and wheel listeners that should be passive to avoid blocking the main thread.
Fix
Auto-fix issues
Refactor this code to batch all DOM reads before writes, eliminating forced synchronous layouts. Mark relevant listeners as passive when they do not need to cancel scrolling.
Explain
Learn more
Explain layout thrashing, what causes it, and how batching reads and writes prevents it.
Review
Code review
Review scripts, client components, and browser execution paths related to Minimize costly DOM read/write operations. Flag exact imports, event handlers, runtime side effects, or blocking operations that violate the rule, and state how the change should be verified in the browser.