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. It is part of Layout and Paint Optimization. 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.

The diagram below maps what each kind of property change re-runs. Some changes invalidate layout (and everything downstream); some skip straight to paint; compositor-only properties skip both. Knowing which lane a mutation lands in tells you whether a geometry read will force a synchronous layout flush. For an exhaustive property-by-property map of which mutations dirty the layout tree versus only the paint records, see Which CSS Properties Trigger Reflow vs Repaint.

Which pipeline stages re-run per property change A property change routed into layout, paint, or composite lanes, showing geometry changes re-run all three stages while transform and opacity re-run only composite. Property change width / color / transform Layout Paint Composite geometry: width, top, font-size re-runs all 3 stages paint-only: color, box-shadow skips layout transform, opacity skips layout + paint

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.

The mechanism is a single dirty bit. A DOM write marks the layout tree dirty but defers the actual recomputation; the very next geometry read cannot return a stale value, so the engine flushes the pending layout right then β€” inside your JavaScript, on the main thread, blocking the current task.

A geometry read after a write forces a synchronous flush A class mutation sets the layout-dirty bit and defers work, but the following offsetHeight read forces the engine to run UpdateLayout immediately before returning a value. Single synchronous JS task element.classList.add() write: sets dirty bit element.offsetHeight read: needs fresh geometry UpdateLayout() forced flush, blocks task dirty bit still set from write layout clean after flush

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. For a step-by-step DevTools walkthrough of spotting these markers in a flame chart, see Finding layout thrashing in DevTools.

Read the flame chart as a nesting stack: the deeper a bar sits, the more synchronous work your one geometry read pulled in. The forced-reflow row is the one that carries the recalc and paint invalidation underneath it.

Nested flame-chart stack for one forced reflow A script evaluation bar contains an offsetHeight read that nests UpdateLayout, StyleRecalc and PaintInvalidation, pushing the frame past its 16.67ms budget. Main thread call stack (top = caller) Script Evaluation β€” 3.8ms HTMLElement.offsetHeight β€” 2.1ms [FORCED SYNC LAYOUT] LayoutTree::UpdateLayout β€” 1.9ms StyleRecalc β€” 0.7ms PaintInvalidation β€” 0.4ms budget 16.67ms Β· actual 19.2ms

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.

The payoff is visible in the flush count. An interleaved read/write loop pays one synchronous layout per read; a read-then-write batch pays a single flush no matter how many elements you touch.

Interleaved reads versus a batched read then write Interleaving reads and writes forces a layout flush per iteration, while grouping all reads before all writes collapses the frame to a single flush. Interleaved β€” 3 flushes read write read write read write 3 forced layouts Batched β€” 1 flush read read read 1 flush writes

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

The single number to watch is total frame time against the 16.67ms budget. Removing the forced reflow pulls the geometry work out of the JS task, so the frame drops back under the line and delivery returns to 60fps.

Frame time before and after removing the forced reflow A before bar of 19.2ms overruns the 16.67ms budget line while the after bar of 9.4ms sits comfortably under it. Frame delivery time vs 16.67ms budget 16.67ms budget before 19.2ms β€” dropped frame after 9.4ms β€” under budget

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.

Detailed Breakdowns

What Triggers a Reflow Versus a Repaint

The distinction that organises this whole topic is that some changes force the engine to recompute geometry (reflow, also called layout) while others only force it to redraw pixels (repaint), and reflow is the more expensive because it is global by default and it feeds repaint. Changing an element’s width, height, margin, padding, top/left, font-size, or adding and removing DOM nodes all change geometry and trigger reflow β€” and because layout can affect siblings and ancestors, the engine may recompute a large subtree. Changing color, background-color, visibility, box-shadow, or outline changes only appearance and triggers repaint of the affected region, which is bounded to the element’s box. Changing transform or opacity on a promoted element skips both, touching only compositing.

Internalising which bucket a property falls into is the fastest way to predict the cost of a change before you write it. If a value affects where boxes sit, expect reflow; if it affects only how they look, expect repaint; if it is transform/opacity, expect neither. The corollary for animation is direct: animating a geometry property runs layout every frame, animating an appearance property runs paint every frame, and animating transform/opacity runs neither β€” which is why the same visual motion can cost wildly different amounts depending on the property you choose to express it with.

// Same visual nudge, three very different costs per frame:
el.style.left = x + 'px'          // reflow  β€” layout + paint + composite
el.style.background = color        // repaint β€” paint + composite
el.style.transform = `translateX(${x}px)` // composite only β€” no layout, no paint

The Forced Reflow Multiplier

Beyond choosing cheap properties, the trap that turns an acceptable reflow into a frame-killer is forcing it synchronously mid-task. Reading a layout-dependent property β€” offsetHeight, getBoundingClientRect(), scrollTop β€” after you have written to the DOM in the same frame forces the engine to flush its pending layout immediately so the read returns a correct value. Do that once and you pay for one extra layout; do it inside a loop over a list, interleaving a read and a write per item, and you pay for one layout per item, which is the classic layout-thrashing multiplier that turns a smooth interaction into a visible stall. The fix is always the same shape: gather all the reads first, against one clean layout, then apply all the writes together.

This is why reflow triggers and forced synchronous layout are two sides of one topic. Knowing which properties trigger reflow tells you what to avoid animating; knowing that reads-after-writes force reflow tells you how to structure the code that touches them. The forced synchronous layouts guide covers the batching pattern in depth, and the DevTools signature β€” a Layout event nested inside a Script call frame with a β€œforced reflow” warning β€” is how you confirm you have found one in a trace. Attack the interleave, and the multiplier collapses back to a single layout at the frame boundary.

A Working Mental Model

The fastest way to reason about the cost of a change before writing it is to sort every property mutation into one of three buckets. Geometry changes β€” width, height, top, margin, font-size, adding or removing nodes β€” trigger reflow, which recomputes layout for a potentially large subtree and then repaints and composites. Appearance changes β€” color, background, box-shadow, visibility β€” trigger repaint of the affected region plus composite, but no layout. Compositor changes β€” transform and opacity on a promoted element β€” trigger neither reflow nor repaint, only a re-composite. The buckets are ordered by cost, and the same visual effect can often be moved down a bucket: a position change expressed as transform: translate instead of left drops from the most expensive bucket to the cheapest.

Carrying this model means you rarely need to profile just to predict cost β€” you can read a proposed change and know whether it will reflow, repaint, or merely composite. Profiling then becomes confirmation rather than exploration: you make the change you expect to be cheap, record a trace, and verify the Main track stayed quiet. When the trace surprises you β€” a Layout where you expected only composite β€” the usual cause is either a property you misfiled or a forced synchronous layout from a read-after-write, and both are quick to spot once you know the buckets. This model, combined with the read-before-write discipline, covers the large majority of layout-and-paint performance work. It also scales to reasoning about third-party code you cannot see: an embedded widget that animates its width will reflow on every frame no matter how well the rest of your page is built, and the mitigation β€” isolating it behind contain: layout so its reflow cannot ripple outward β€” follows directly from the model. The same applies to CSS you inherit from a framework: a transition on a geometry property in a component library is a reflow-per-frame cost you can spot by classifying the property, then fix by overriding the transition to use transform instead. Reading changes through the three-bucket lens turns performance review into a quick classification pass rather than a profiling expedition, and it makes the expensive cases obvious before they ship rather than after a user reports jank. The habit compounds across a team: when everyone classifies a proposed animation or DOM mutation by which bucket it lands in, the expensive patterns get caught in code review, and the profiler is reserved for the genuinely surprising cases where a change costs more than its bucket predicts β€” almost always a forced synchronous layout hiding a read after a write. Over time this shared vocabulary β€” reflow, repaint, composite β€” becomes the language a team uses to discuss rendering cost, and that shared language is worth as much as any single optimisation, because it lets everyone spot the expensive pattern without having to rediscover the pipeline each time.

Frequently Asked Questions

What is the difference between reflow and repaint?

Reflow (layout) recomputes the geometry of elements β€” position and size β€” and cascades to every descendant and often the whole document. Repaint only redraws pixels for a property like color or box-shadow without changing geometry. Reflow always forces a repaint of the affected area; a repaint does not force a reflow. Compositor-only properties such as transform and opacity skip both.

Which DOM reads force a synchronous reflow?

Any read that needs up-to-date geometry: offsetWidth/offsetHeight, offsetTop/offsetLeft, clientWidth/clientHeight, scrollWidth/scrollHeight/scrollTop, getBoundingClientRect(), and getComputedStyle() for a layout property. They force a flush only when there are pending style or DOM changes queued; reading them with a clean layout tree is cheap.

Does changing transform or opacity cause a reflow?

No. transform and opacity are compositor-only properties on a promoted layer, so they skip both layout and paint and run on the compositor thread. That is why the mitigation pattern writes animated movement with transform: translateY() instead of top or margin, which would dirty the layout tree.

How do I find a forced reflow in DevTools?

Record a Performance trace during the interaction, filter the main thread for Layout events, and look for entries flagged as a forced reflow. Expand the event to read the JavaScript call stack that triggered it, then check whether the layout was subtree-scoped or full-document. A full-document layout is the most expensive outcome.

Why does batching reads before writes help?

The engine batches queued invalidations and resolves them once per frame. Interleaving a read after each write forces a separate synchronous flush per iteration. Grouping all geometry reads first, then all writes, lets the browser collapse the frame to a single layout pass regardless of how many elements you touch.