DOM Reflow, Repaint & Rendering Pipeline

Reviewed & published by Brayan K

Modern web performance starts with understanding how the browser actually turns your HTML, CSS, and JavaScript into pixels on the screen. This lesson reveals the rendering pipeline, reflow/repaint costs, and professional optimization techniques used by Meta, Google, Netflix, and Airbnb.

Part of the free JavaScript course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

💡 Running Code Locally: While this online editor runs real JavaScript, some advanced examples may have limitations. For the best experience:

A single line of code—changing width, reading offsetHeight, or triggering layout—can destroy 60 FPS performance. Learn to write code that keeps your UI smooth even on low-power devices.

🚀 The Browser Rendering Pipeline

The browser doesn't magically draw pixels. It follows a detailed sequence every frame:

1. HTML → DOM Tree

Browser parses HTML and creates nodes representing every tag.

2. CSS → CSSOM Tree

Browser parses stylesheets, computes rules, builds CSS Object Model.

3. Render Tree

DOM + CSSOM merge into structure defining what's visible.

4. Layout (Reflow) 🟥 EXPENSIVE

Every visible element receives size, position, geometry, box model values.

5. Paint (Repaint) 🟨 COSTLY

Colors, borders, shadows, backgrounds, text are drawn.

6. Compositing 🟩 FAST

Layers are merged into final image on your screen (GPU-accelerated).

This entire cycle repeats every time something changes visually. A smooth 60 FPS requires all tasks to fit inside a 16.6ms frame budget!

🟥 What Causes Reflow (Most Expensive)

Reflow recalculates layout—the most expensive operation. Common triggers:

🔥 Forced Reflow Properties (Dangerous in Loops)

element.offsetHeight
element.offsetWidth
element.scrollTop
element.getBoundingClientRect()
window.getComputedStyle(element)

These force the browser to apply pending layout changes before returning a value!

🟨 What Causes Repaint (Cheaper but Not Free)

Repaint happens when appearance changes but geometry does not:

div.style.backgroundColor = "red"; // repaint only, no layout

🟩 Best Case: Compositor-Only Changes

Using transform or opacity avoids both reflow and repaint. These changes happen on the GPU compositor thread—extremely fast!

box.style.transform = "translateX(30px)";  
box.style.opacity = "0.7";
// No layout, no paint — just compositing

This is why all modern animation libraries use transform and opacity. Always prefer these for smooth 60 FPS animations.

🧨 Layout Thrashing: The Performance Killer

Layout thrashing happens when you repeatedly mix layout reads and writes in the same execution cycle. This causes hundreds of reflows!

// ❌ BAD: Layout thrashing
function badResize(items) {
  for (let i = 0; i < items.length; i++) {
    // Forces layout read
    const width = items[i].offsetWidth;
    // Triggers layout write
    items[i].style.width = width + 5 + "px";
    // Repeat hundreds of times = disaster
  }
}

// ✅ GOOD: Batch reads, then batch writes
function goodResize(items) {
  // Read all at once
  const widths = items.map(item => item.offsetWidth);
  
  // Write all at once
  items.forEach((item, i) => {
    item.style.width = widths[i] + 5 + "px";
  });
}

// Test it
const boxes = document.querySelectorAll('.box');
goodResize(boxes);

Batch all reads together, then batch all writes together. This avoids thrashing and reduces reflows massively.

🔬 Worked Example: Count the Reflows

"Reduces reflows massively" is easy to say and hard to believe until you can see a number. The browser will not give you one, so the example below uses a small stand-in for the layout engine that follows the same rule the real one does: writes are queued and cost almost nothing, and a read forces the browser to stop and recalculate whatever is queued.

It is a model, not the real engine — but the count it prints is exactly the count of forced reflows your code would cause. The browser still lays the page out once at the end of the frame; that one is unavoidable and cheap. The ones you are hunting are the extra ones your loop forces in the middle of your JavaScript.

// A stand-in for the browser's layout engine, so you can COUNT the damage.
// The browser will never tell you how many forced reflows you caused, but
// this model follows the exact rule the real engine follows:
//   - a style WRITE is queued and costs almost nothing on its own
//   - a layout READ forces the browser to stop and recalculate anything queued

const layout = {
  pending: 0,     // style writes waiting to be applied
  reflows: 0,     // how many times we forced a recalculation

  write(px) {     // stands in for: element.style.width = px + "px"
    this.pending++;         // queued. Cheap. Nothing is recalculated yet.
  },

  read() {        // stands in for: element.offsetWidth
    if (this.pending > 0) { // there is queued work, so the browser MUST
      this.reflows++;       // recalculate layout before it can answer you
      this.pending = 0;
    }
    return 100;             // a pretend measurement
  },

  reset() { this.pending = 0; this.reflows = 0; }
};

const items = [1, 2, 3, 4, 5];   // pretend five elements

// ❌ THRASHING — read, write, read, write, ...
// Every read that follows a write forces another recalculation.
layout.reset();
items.forEach(() => {
  const width = layout.read();   // forces a reflow once a write is pending
  layout.write(width + 5);       // queues the next write
});
console.log("thrashing reflows:", layout.reflows);

// ✅ BATCHED — all the reads first, then all the writes.
layout.reset();
const widths = items.map(() => layout.read());   // reads only, nothing pending
widths.forEach(w => layout.write(w + 5));        // writes only, nothing forced
console.log("batched reflows:", layout.reflows);

// The gap grows with the list. Same work, same result, different order.
layout.reset();
const many = new Array(500).fill(0);
many.forEach(() => { const w = layout.read(); layout.write(w + 1); });
console.log("500 items, thrashing:", layout.reflows);

layout.reset();
const readAll = many.map(() => layout.read());
readAll.forEach(w => layout.write(w + 1));
console.log("500 items, batched:", layout.reflows);

// ✅ Expected output:
// thrashing reflows: 4
// batched reflows: 0
// 500 items, thrashing: 499
// 500 items, batched: 0

Look at the 500-item figures: 499 forced reflows versus none. The two loops do identical work and produce identical results. The only difference is the order of the reads and the writes.

🎯 Your Turn: Batch the Loop

Your turn. The thrashing loop is written out so you can compare against it; you write the batched version underneath. Two blanks, one idea. Fill in each ___ and check your numbers against the expected output at the bottom of the file.

// 🎯 YOUR TURN — turn a thrashing loop into a batched one
// The layout model is written for you. Replace the two ___ below.

const layout = {
  pending: 0,
  reflows: 0,
  write() { this.pending++; },
  read() {
    if (this.pending > 0) { this.reflows++; this.pending = 0; }
    return 40;
  },
  reset() { this.pending = 0; this.reflows = 0; }
};

const boxes = [1, 2, 3, 4, 5, 6];

// The thrashing version, for comparison. Leave this one alone.
layout.reset();
boxes.forEach(() => {
  const h = layout.read();
  layout.write(h * 2);
});
console.log("thrashing reflows:", layout.reflows);

// 🎯 Your version: do ALL the reads first, then ALL the writes.
layout.reset();

const heights = boxes.___(() => layout.read());   // 👉 which array method collects a value from every item?

heights.forEach(h => layout.___(h * 2));          // 👉 which layout method queues a style change?

console.log("batched reflows:", layout.reflows);
console.log("same number of writes?", layout.pending === boxes.length);

// ✅ Expected output:
// thrashing reflows: 5
// batched reflows: 0
// same number of writes? true

🎯 Efficient vs Inefficient Animations

The difference between laggy and smooth animations often comes down to which properties you animate:

// ❌ BAD: Triggers reflow every frame
function animateBad(element) {
  setInterval(() => {
    element.style.left = element.offsetLeft + 2 + "px";
  }, 16);
}

// ✅ GOOD: Uses compositor (GPU)
function animateGood(element) {
  let x = 0;
  
  function animate() {
    x += 2;
    element.style.transform = `translateX(${x}px)`;
    requestAnimationFrame(animate);
  }
  
  animate();
}

// Even better: with will-change
const box = document.querySelector('.animate-box');
box.style.willChange = 'transform';
animateGood(box);

Why transform is faster:

🕹️ Real-World: Smooth Scroll Performance

Scroll events fire hundreds of times per second. Without optimization, they destroy performance:

// ❌ BAD: Triggers layout on every scroll
window.addEventListener('scroll', () => {
  const sidebar = document.querySelector('.sidebar');
  sidebar.style.top = window.scrollY + "px";
});

// ✅ GOOD: GPU-accelerated with throttle
function throttle(fn, limit) {
  let inThrottle = false;
  return function(...args) {
    if (!inThrottle) {
      fn.apply(this, args);
      inThrottle = true;
      setTimeout(() => (inThrottle = false), limit);
    }
  };
}

const sidebar = document.querySelector('.sidebar');
sidebar.style.willChange = 'transform';

window.addEventListener('scroll', throttle(() => {
  sidebar.style.transform = `translateY(${scrollY}px)`;
}, 16));

🔥 DocumentFragment: Batch DOM Changes

When adding multiple elements, use DocumentFragment to avoid multiple reflows:

// ❌ BAD: Multiple reflows
function addItemsSlow(container, count) {
  for (let i = 0; i < count; i++) {
    const div = document.createElement('div');
    div.textContent = `Item ${i}`;
    container.appendChild(div); // reflow each time
  }
}

// ✅ GOOD: Single reflow with fragment
function addItemsFast(container, count) {
  const fragment = document.createDocumentFragment();
  
  for (let i = 0; i < count; i++) {
    const div = document.createElement('div');
    div.textContent = `Item ${i}`;
    fragment.appendChild(div); // no reflow
  }
  
  container.appendChild(fragment); // single reflow
}

// Test the difference
const list = document.querySelector('.list');
console.time('fast');
addItemsFast(list, 1000);
console.timeEnd('fast');

🧬 Compositor Layers & GPU Acceleration

Modern browsers split the page into layers. Certain properties automatically create a new layer:

Force GPU layer for animation:

.box {
  will-change: transform;
}

// Now this runs on GPU:
box.style.transform = "translateY(120px)";

Don't overuse will-change. Too many layers consume GPU memory. Only use on elements that actually animate frequently.

🧠 Chrome DevTools Performance Debugging

Professional engineers use these techniques to find performance bottlenecks:

1. Performance Panel

2. Enable Paint Flashing

3. Rendering → Layout Shift Regions

Highlights areas causing layout shifts—essential for Core Web Vitals optimization.

🏁 Professional Performance Checklist

Follow this checklist to achieve near-professional performance:

✅ Always Do

❌ Never Do

🎯 Mini-Challenge: Write the Batching Scheduler

Real code does not arrive pre-sorted into reads and writes. Write the scheduler that sorts it for you. Nothing is filled in — you get the rules and a fixed test drive, and if your output matches the expected output at the bottom, your scheduler works.

// 🎯 MINI-CHALLENGE: write the batching scheduler
//
// Real code does not arrive neatly sorted into reads and writes — it turns up
// as a mixed list of jobs. Write runBatched(jobs) from scratch. Only the brief
// is here.
//
// runBatched(jobs) takes an array of jobs shaped { kind, label }, where kind
// is either "read" or "write", and it must:
//
// 1. Run EVERY read job first, in their original order. For each one, call
//    layout.read() and print:   read LABEL
// 2. Then run every write job, in their original order. For each one, call
//    layout.write() and print:  write LABEL
// 3. Finally print:             forced reflows: N
//    using layout.reflows.
//
// Do not change the layout object below.

const layout = {
  pending: 0,
  reflows: 0,
  write() { this.pending++; },
  read() {
    if (this.pending > 0) { this.reflows++; this.pending = 0; }
    return 40;
  },
  reset() { this.pending = 0; this.reflows = 0; }
};

// your code here


// --- Do not change the code below: this is the test drive ---
layout.reset();
runBatched([
  { kind: "write", label: "a" },
  { kind: "read",  label: "b" },
  { kind: "write", label: "c" },
  { kind: "read",  label: "d" }
]);

// ✅ Expected output:
// read b
// read d
// write a
// write c
// forced reflows: 0

🎓 Practice Challenges

Challenge 1: Fix the Laggy Slider

Optimize this mousemove slider to use transforms and throttling for 60 FPS performance.

slider.addEventListener("mousemove", () => {
  handle.style.left = event.clientX + "px";
});

Challenge 2: Optimize Infinite Scroll

Refactor an infinite scroll implementation to use DocumentFragment and IntersectionObserver instead of scroll events and individual DOM appends.

Challenge 3: Parallax Without Jank

Create a smooth parallax scrolling effect using transforms and requestAnimationFrame that maintains 60 FPS even on mobile devices.

🎯 Key Takeaways

📋 Quick Reference

ConceptImpact
ReflowExpensive — recalculates layout geometry
RepaintCheaper — redraws pixels without layout
transform/opacityGPU-accelerated, no reflow
offsetWidthForces layout — use sparingly
DocumentFragmentBatch DOM changes into one reflow

Lesson Complete!

You now understand the full browser rendering pipeline and how to write code that keeps UIs smooth at 60 FPS.

Practice quiz

What is the correct order of the browser's rendering pipeline?

  • Paint → Layout → DOM → Compositing
  • CSSOM → Paint → DOM → Layout
  • DOM → CSSOM → Render Tree → Layout → Paint → Compositing
  • Render Tree → DOM → Paint → Layout

Answer: DOM → CSSOM → Render Tree → Layout → Paint → Compositing. The pipeline is DOM → CSSOM → Render Tree → Layout (Reflow) → Paint → Compositing.

Which step is the most expensive according to the lesson?

  • Layout (Reflow)
  • Compositing
  • Paint
  • Parsing HTML

Answer: Layout (Reflow). Layout (Reflow) recalculates element geometry and is the most expensive operation.

What is layout thrashing?

  • Animating with transform
  • Loading too many images
  • Using too much CSS
  • Repeatedly mixing layout reads and writes in the same cycle, causing many reflows

Answer: Repeatedly mixing layout reads and writes in the same cycle, causing many reflows. Layout thrashing happens when you interleave layout reads and writes, forcing hundreds of reflows.

What is the golden rule to avoid layout thrashing?

  • Avoid all CSS
  • Batch all reads together, then batch all writes together
  • Use setInterval for updates
  • Read layout inside loops

Answer: Batch all reads together, then batch all writes together. Batch all reads first, then all writes, to avoid interleaving that triggers repeated reflows.

Which CSS properties avoid both reflow and repaint by running on the GPU compositor?

  • transform and opacity
  • width and height
  • top and left
  • margin and padding

Answer: transform and opacity. transform and opacity changes happen on the GPU compositor thread, avoiding layout and paint.

Which of these forces a synchronous (forced) reflow when read?

  • element.className
  • element.dataset
  • element.offsetHeight
  • element.id

Answer: element.offsetHeight. Reading layout properties like offsetHeight forces the browser to apply pending layout before returning a value.

What causes a repaint without a reflow?

  • Changing width
  • Changing background-color or color (appearance without geometry change)
  • Adding elements
  • Changing display

Answer: Changing background-color or color (appearance without geometry change). Changing appearance like color or background-color repaints but does not change geometry, so no reflow.

Why use a DocumentFragment when adding many elements?

  • It encrypts the DOM
  • It speeds up the network
  • It styles the elements
  • It batches the additions into a single reflow instead of one per element

Answer: It batches the additions into a single reflow instead of one per element. Appending to a DocumentFragment off-DOM and inserting it once causes a single reflow instead of many.

To hit smooth 60 FPS, how much time does each frame have?

  • About 100ms
  • About 16.6ms
  • About 1 second
  • About 5ms

Answer: About 16.6ms. 60 FPS requires all work to fit inside roughly a 16.6ms frame budget.

What is a downside of overusing will-change?

  • It disables animations
  • It triggers reflow every frame
  • Too many layers consume GPU memory
  • It blocks the network

Answer: Too many layers consume GPU memory. will-change promotes elements to GPU layers; overusing it creates too many layers that consume GPU memory.

Continue this course