Code of the Day
AdvancedBrowser Rendering

Reflow and Repaint

Reflow recalculates geometry for all affected elements — an expensive operation triggered by DOM or style changes that affect layout.

Web FoundationsAdvanced7 min read
By the end of this lesson you will be able to:
  • Distinguish reflow, repaint, and compositing as three levels of the rendering cost hierarchy
  • Identify the DOM operations and CSS properties that trigger each level
  • Explain what layout thrashing is and how to eliminate it in JavaScript
  • Choose compositor-friendly properties for smooth animations

The render tree the browser builds at load time is not static. Every time JavaScript changes the DOM or a user interaction modifies a style, the browser has to update the screen. How much it has to do depends on what changed — and the difference between the three levels spans orders of magnitude in cost.

The cost hierarchy

There are three levels at which the browser can update the screen, from most expensive to least:

  1. — recalculates the position and size of every affected element in the render tree. Expensive because a single change can cascade: widening one element can move its siblings, resize its parent, and shift everything that follows.
  2. — redraws pixels without recalculating geometry. Cheaper, but still forces the CPU to walk the affected region and fill in colour, text, images, and shadows.
  3. — the GPU rearranges or blends existing painted layers. The CPU is not involved at all. This is the target for smooth, 60 fps animation.

What triggers each level

Reflow triggers

Reflow runs whenever the browser needs to recalculate geometry. Common causes:

  • Changing an element's dimensions (width, height, padding, border, margin)
  • Adding or removing DOM elements
  • Changing font-size or line-height
  • Resizing the browser window
  • Changing display (e.g. from none to block)
  • Reading certain layout properties from JavaScript (see below)
/* these changes trigger reflow */
.box { width: 200px; }   /* geometry changed */
.box { padding: 16px; }  /* geometry changed */

Repaint-only triggers

A change that affects appearance but not geometry stays at the repaint level:

/* these trigger repaint only — geometry is unchanged */
.box { color: tomato; }
.box { background-color: #f0f; }
.box { outline: 2px solid navy; }
.box { visibility: hidden; }   /* space is still reserved */

Compositor-only triggers

Two CSS properties are special: transform and opacity. The browser can animate them entirely on the GPU without involving the CPU's layout or paint steps:

/* compositor only — no reflow, no repaint */
.box { transform: translateX(100px); }
.box { opacity: 0.5; }

This is why transform: translateX() is the correct tool for moving an element on screen, while left: or margin-left: would trigger a reflow on every animation frame.

will-change: transform is a hint to the browser that an element will be animated, so it can promote the element to its own compositor layer in advance. Use it sparingly — every promoted layer consumes GPU memory, and promoting hundreds of elements is worse than not promoting any.

Layout thrashing

The most common JavaScript-induced rendering performance problem is — interleaving DOM reads and writes in a way that forces the browser to flush pending reflows repeatedly.

Here is the pattern to avoid:

// BAD — reads and writes interleaved
const box1 = document.getElementById('box1');
const box2 = document.getElementById('box2');

box1.style.width = '200px';           // write: marks layout dirty
const h = box2.offsetHeight;          // read:  browser must reflow NOW to answer
box2.style.height = h + 'px';         // write: marks layout dirty again
const w = box1.offsetWidth;           // read:  browser must reflow AGAIN

Each time you write a style and then immediately read a layout property (offsetHeight, offsetWidth, getBoundingClientRect, scrollTop, etc.), the browser must flush all pending style changes and recompute geometry before it can give you the accurate value. In a loop over many elements, this becomes catastrophic.

The fix is simple: batch all reads first, then batch all writes:

// GOOD — reads first, writes second
const box1 = document.getElementById('box1');
const box2 = document.getElementById('box2');

// Read phase — one layout flush at most
const h = box2.offsetHeight;
const w = box1.offsetWidth;

// Write phase — one layout invalidation
box1.style.width = '200px';
box2.style.height = h + 'px';

The browser batches the writes and performs a single reflow before the next paint, rather than one per read-write cycle.

Layout properties that trigger forced synchronous layout when read after a write include: offsetTop, offsetLeft, offsetWidth, offsetHeight, scrollTop, scrollLeft, scrollWidth, scrollHeight, getBoundingClientRect(), getComputedStyle(), and several others. Avoid reading them inside loops that also write styles.

Practical rules

GoalApproach
Move an element smoothlytransform: translate() not left / top
Fade an elementopacity not visibility toggle with animation
Show/hide without layout costopacity + pointer-events: none not display: none
Batch DOM mutationsBuild a DocumentFragment or use requestAnimationFrame
Read layout metricsDo it before any writes in the same frame

Where to go next

Now that you can see how individual changes propagate through the pipeline, the next lesson zooms out to the whole page load: The Critical Rendering Path — the ordered sequence the browser must complete before it can paint the very first frame, and the specific resources that block that sequence.

Finished reading? Mark it complete to track your progress.

On this page