Skip to main content
Beta: Front-End Checklist is currently in beta. Some issues are still being fixed. Thanks for your patience.

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.

Utilities
Quick take
Typical fix time 20 min
  • 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
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.

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 reflow

CSS 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

  1. Verify the behavior in the browser after the code change, not only in static analysis.
  2. Inspect DevTools Network or Performance panels when the rule affects loading or execution order.
  3. Test the primary user flow and one edge case triggered by the changed script path.
  4. Confirm the code still behaves correctly when the feature is delayed, lazy-loaded, or fails.
  5. 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.

Sources

References used to support the guidance in this rule.

Further Reading

Tools and supplementary material for exploring the topic in more depth.

Chrome DevTools Performance paneldeveloper.chrome.comTool
FastDOM librarygithub.comTool

Rules that often go hand-in-hand with this one.

Prevent common memory leak patterns

Identify and avoid the most common JavaScript memory leak sources: forgotten event listeners, retained DOM references, closures holding large objects, and uncleared timers.

JavaScript
Load scripts with defer, async, or type=module

Prevent JavaScript from blocking HTML parsing by using defer, async, or type=module attributes on script tags so the browser can continue building the DOM while scripts download.

HTML
Split large JavaScript bundles

Use dynamic imports and route-based code splitting to break large bundles into smaller chunks that load on demand, reducing initial page load time.

JavaScript
Lazy load offscreen images

Images below the visible viewport use loading="lazy" to defer download until the user scrolls near them, reducing initial page load time.

Images

Was this rule helpful?

Your feedback helps improve rule quality. This stays internal for now.

Loading feedback...
0 / 385