Reflow and Repaint Triggers

The browser rendering pipeline defers style and layout resolution to a batched step at the end of each task. This batching is what makes multiple DOM mutations in the same task relatively cheap: the engine queues all the invalidations and processes them once. Synchronous layout queries break that batching by forcing the engine to flush the pending queue immediately and return an up-to-date geometry value before the current task finishes.

This document covers identifying, tracing, and eliminating forced reflows. For batching patterns, see How to batch DOM reads and writes to prevent thrashing. For CSS-level strategies, see CSS Containment Strategies and will-change and Layer Hints.

1. Identifying the Trigger

Any property read that requires up-to-date geometry causes a forced synchronous layout if there are pending style changes. Common triggers:

  • offsetHeight, offsetWidth, offsetTop, offsetLeft
  • clientHeight, clientWidth, clientTop, clientLeft
  • scrollHeight, scrollWidth, scrollTop, scrollLeft
  • getBoundingClientRect()
  • getComputedStyle(element).someLayoutProperty
// ❌ Forced synchronous layout on every iteration
element.classList.add('expanded')       // Write: invalidates layout tree
const height = element.offsetHeight    // Read: forces immediate layout flush
element.style.marginTop = `${height}px` // Write: invalidates again

Audit pattern: Search component lifecycle hooks and event handlers for any geometry read immediately following a DOM mutation. Each one is a forced reflow.

2. Trace Analysis

Record a Performance trace during the target interaction. Filter the Main thread for Layout events. Expand the Layout event in the Summary tab to see the JavaScript call stack that triggered it.

[Main Thread]
└─ Script Evaluation (3.8ms)
   └─ HTMLElement.offsetHeight (2.1ms)  [FORCED SYNC LAYOUT]
      └─ LayoutTree::UpdateLayout (1.9ms)
         └─ StyleRecalc (0.7ms)
            └─ PaintInvalidation (0.4ms)
Frame Budget: 16.67ms | Actual: 19.2ms — DROPPED

DevTools workflow:

  1. Performance panel → Enable Disable JavaScript cache and Capture screenshots.
  2. Record → Execute interaction → Stop.
  3. Filter by Layout → Expand Forced Reflow markers.
  4. Click the marker → Review Call Stack to trace back to the originating JS function.
  5. Check the Layout summary to see whether it is a subtree layout or a full-document layout.

A full-document layout is the most expensive outcome. It means the engine recomputed geometry for the entire document rather than just the subtree under the element being queried.

3. Mitigation

The fundamental fix is to separate geometry reads from DOM writes across the task boundary.

// ✅ Read all geometry first, then write in a rAF callback
requestAnimationFrame(() => {
  // Phase 1: reads (single layout flush — already scheduled by the browser)
  const currentHeight = element.offsetHeight
  const targetHeight = calculateTarget(currentHeight)

  // Phase 2: writes — use compositor-safe properties where possible
  element.style.transform = `translateY(${targetHeight}px)`
  element.style.opacity = '1'
})

Using transform for the write phase promotes the change to the compositor thread and avoids triggering another layout flush. When a geometry-affecting property must change (e.g., height, width), batch all writes after all reads to minimize the number of layout flushes per frame.

For unavoidable cases where both a synchronous read and a geometry-affecting write are needed in the same tick, use ResizeObserver to observe dimension changes reactively rather than polling:

// ResizeObserver fires at the right time in the rendering lifecycle,
// after layout has completed — no forced reflow
const observer = new ResizeObserver((entries) => {
  const { width, height } = entries[0].contentRect
  // Safe to use width/height here without forcing a layout
  element.style.setProperty('--widget-height', `${height}px`)
})
observer.observe(target)

4. Validation

Metric Pre-optimization Target
Forced synchronous layouts per frame >0 0
Layout event duration > 10ms < 4ms
Paint duration > 8ms < 2ms
Frame delivery rate < 60fps ≥ 60fps

CI validation pattern:

// Playwright / Puppeteer: assert zero forced reflows during critical path
const trace = await page.evaluate(() =>
  performance.getEntriesByType('navigation')
)
// Parse the Performance trace JSON exported from CDP for Layout events
// with the `forced` flag set to `true`
// assert: forced_layout_count === 0

Integrate Lighthouse CI to track TBT and INP regressions. A TBT increase after a refactor often traces back to a newly introduced forced reflow.