The DOM is the most performance-sensitive part of frontend development. Every read that causes a layout recalculation and every write that invalidates layout creates work for the browser’s rendering engine. Naive DOM code that looks simple can cause janky, dropped-frame animations and slow page interactions. In this lesson you will learn the browser rendering pipeline, layout thrashing and how to prevent it, the modern IntersectionObserver and MutationObserver APIs, requestAnimationFrame for smooth animation, and virtual scrolling for massive lists.
The Browser Rendering Pipeline
| Stage | What Happens | Triggered By |
|---|---|---|
| Style | Compute CSS for each element | Class/style changes |
| Layout (Reflow) | Calculate size and position of every element | Size, position, font changes |
| Paint | Draw pixels for each element | Color, shadow, background changes |
| Composite | Layer the painted elements onto screen | transform, opacity changes |
Layout Thrashing: Reading While Dirty
| Property Read (forces layout) | Property Write (invalidates layout) |
|---|---|
offsetWidth, offsetHeight, offsetTop |
el.style.width = '...' |
clientWidth, clientHeight |
el.style.height = '...' |
getBoundingClientRect() |
el.className = '...' |
scrollTop, scrollLeft |
el.style.margin = '...' |
getComputedStyle(el) |
el.innerHTML = '...' |
Observer APIs
| Observer | Purpose | Key Use Cases |
|---|---|---|
IntersectionObserver |
Watch when element enters/exits viewport | Lazy loading images, infinite scroll, animate-on-scroll |
MutationObserver |
Watch for DOM changes | Third-party DOM changes, custom element polyfills |
ResizeObserver |
Watch element size changes | Responsive components, chart re-rendering |
PerformanceObserver |
Watch performance entries | Real user monitoring, core web vitals |
transform and opacity run on the compositor thread โ they bypass the Layout and Paint stages entirely. This is why transform: translateX(100px) is smoother than left: 100px. Whenever possible, animate only transform and opacity for 60fps animations without layout cost.IntersectionObserver is the modern replacement for scroll event listeners that check element positions. A scroll listener fires hundreds of times per second and calls getBoundingClientRect() (which forces layout) on every call. IntersectionObserver is asynchronous โ the browser calls your callback only when visibility changes, using no layout cost at all.Basic Example
// โโ Layout thrashing โ DON'T DO THIS โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function thrashingBad(boxes) {
boxes.forEach(box => {
const w = box.offsetWidth; // READ โ forces layout
box.style.width = w * 2 + 'px'; // WRITE โ invalidates layout
// Next iteration: READ forces layout again โ thrashing!
});
}
// โโ Batch reads then writes โ DO THIS โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function batchedGood(boxes) {
// Phase 1: Read all values (one layout calculation)
const widths = boxes.map(box => box.offsetWidth);
// Phase 2: Write all values (layout only calculated once after)
boxes.forEach((box, i) => {
box.style.width = widths[i] * 2 + 'px';
});
}
// โโ requestAnimationFrame โ smooth animation โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
function animateWidth(element, from, to, duration) {
const start = performance.now();
function step(timestamp) {
const elapsed = timestamp - start;
const progress = Math.min(elapsed / duration, 1);
// Ease-in-out cubic
const eased = progress < 0.5
? 4 * progress ** 3
: 1 - (-2 * progress + 2) ** 3 / 2;
element.style.width = `${from + (to - from) * eased}px`;
if (progress < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
animateWidth(document.querySelector('.bar'), 0, 300, 800);
// โโ IntersectionObserver โ lazy load images โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const imageObserver = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
if (!entry.isIntersecting) return;
const img = entry.target;
img.src = img.dataset.src; // swap placeholder with real src
img.classList.add('loaded');
observer.unobserve(img); // done โ stop watching this image
});
}, {
rootMargin: '200px', // load 200px before entering viewport
threshold: 0,
});
document.querySelectorAll('img[data-src]').forEach(img => {
imageObserver.observe(img);
});
// โโ IntersectionObserver โ animate on scroll โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const animateObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
entry.target.classList.toggle('visible', entry.isIntersecting);
});
}, { threshold: 0.15 }); // trigger when 15% visible
document.querySelectorAll('.animate-on-scroll').forEach(el => {
animateObserver.observe(el);
});
// โโ MutationObserver โ watch for DOM changes โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
const mutationObserver = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
console.log('Node added:', node.tagName);
}
});
}
if (mutation.type === 'attributes') {
console.log(`Attribute "${mutation.attributeName}" changed on`, mutation.target);
}
});
});
mutationObserver.observe(document.querySelector('#app'), {
childList: true, // watch for added/removed children
subtree: true, // include all descendants
attributes: true, // watch for attribute changes
});
// โโ ResizeObserver โ respond to element size changes โโโโโโโโโโโโโโโโโโโโโ
const resizeObserver = new ResizeObserver(entries => {
entries.forEach(({ target, contentRect }) => {
const { width, height } = contentRect;
// Re-render chart or adjust layout
console.log(`${target.id}: ${width}x${height}`);
if (width < 400) target.classList.add('compact');
else target.classList.remove('compact');
});
});
document.querySelectorAll('.responsive-chart').forEach(el => {
resizeObserver.observe(el);
});
How It Works
Step 1 โ The Rendering Pipeline Is Triggered by DOM Changes
Every time you change the DOM or CSS, the browser marks the layout as “dirty”. The next time you read a layout property (like offsetWidth), the browser must synchronously recalculate layout to give you the correct value. If you do this inside a loop โ write, read, write, read โ you force a separate layout calculation on every iteration.
Step 2 โ requestAnimationFrame Syncs With the Display
requestAnimationFrame(callback) schedules your callback to run just before the browser’s next paint โ typically 60 times per second (every ~16ms). All DOM reads and writes inside the callback happen in one frame. The browser can also pause it when the tab is hidden, saving CPU. Using rAF instead of setInterval for animations prevents dropped frames.
Step 3 โ IntersectionObserver Is Asynchronous
Instead of checking element visibility in a scroll listener, IntersectionObserver lets the browser notify you asynchronously when an observed element’s intersection with the viewport changes. The browser uses efficient internal tracking โ no layout queries are needed. Your callback receives an array of IntersectionObserverEntry objects with isIntersecting, intersectionRatio, and boundingClientRect.
Step 4 โ transform and opacity Are Compositor-Only
Most CSS properties โ width, height, margin, top, left โ require layout recalculation when animated. transform and opacity are handled by the GPU compositor, which runs independently of the main thread. This means smooth 60fps animations even when the main thread is busy processing JavaScript.
Step 5 โ MutationObserver Watches the Tree Efficiently
Rather than polling the DOM for changes, MutationObserver registers a callback invoked by the browser whenever the observed subtree changes. You control what to watch: child additions/removals (childList), attribute changes, character data. Always call observer.disconnect() when you no longer need to watch, to prevent memory leaks.
Real-World Example: Virtual Scroll for Large Lists
// virtual-scroll.js โ render only visible rows
class VirtualScroll {
#container;
#items;
#itemHeight;
#visibleCount;
#scrollTop = 0;
constructor(selector, items, itemHeight = 48) {
this.#container = document.querySelector(selector);
this.#items = items;
this.#itemHeight = itemHeight;
this.#visibleCount = Math.ceil(this.#container.clientHeight / itemHeight) + 2;
this.#container.style.overflowY = 'auto';
this.#container.style.position = 'relative';
// Total height spacer โ makes scrollbar correct size
const spacer = document.createElement('div');
spacer.style.height = `${items.length * itemHeight}px`;
this.#container.appendChild(spacer);
this.#render();
this.#container.addEventListener('scroll', () => {
this.#scrollTop = this.#container.scrollTop;
requestAnimationFrame(() => this.#render());
});
}
#render() {
const startIndex = Math.floor(this.#scrollTop / this.#itemHeight);
const endIndex = Math.min(startIndex + this.#visibleCount, this.#items.length);
const frag = document.createDocumentFragment();
for (let i = startIndex; i < endIndex; i++) {
const row = document.createElement('div');
row.className = 'virtual-row';
row.style.cssText = `
position: absolute;
top: ${i * this.#itemHeight}px;
height: ${this.#itemHeight}px;
width: 100%;
`;
row.textContent = this.#items[i]; // safe
frag.appendChild(row);
}
// Replace only rendered rows โ not the spacer
const existing = this.#container.querySelectorAll('.virtual-row');
existing.forEach(el => el.remove());
this.#container.appendChild(frag);
}
}
// Render 100,000 items โ only ~20 DOM nodes at any time
const items = Array.from({ length: 100_000 }, (_, i) => `Row ${i + 1}: Item data here`);
const vscroll = new VirtualScroll('#list-container', items, 48);
Common Mistakes
Mistake 1 โ Scroll listener with getBoundingClientRect for visibility
โ Wrong โ triggers layout on every scroll event (hundreds per second):
window.addEventListener('scroll', () => {
elements.forEach(el => {
const rect = el.getBoundingClientRect(); // forces layout!
if (rect.top < window.innerHeight) el.classList.add('visible');
});
});
โ Correct โ use IntersectionObserver:
const observer = new IntersectionObserver(entries =>
entries.forEach(e => e.target.classList.toggle('visible', e.isIntersecting))
);
elements.forEach(el => observer.observe(el));
Mistake 2 โ Animating layout properties instead of transform
โ Wrong โ causes reflow on every frame:
// Animating 'left' requires layout recalculation every frame
el.style.left = x + 'px';
โ Correct โ use transform for GPU-composited animation:
el.style.transform = `translateX(${x}px)`; // no layout cost
Mistake 3 โ Not disconnecting observers
โ Wrong โ observer continues running after component is removed:
const observer = new IntersectionObserver(callback);
elements.forEach(el => observer.observe(el));
// Component removed โ observer still holds references โ memory leak
โ Correct โ disconnect when done:
// When component unmounts:
observer.disconnect(); // stop observing all elements
Quick Reference
| Task | Approach |
|---|---|
| Batch DOM reads/writes | All reads first, then all writes โ never interleave |
| Smooth animation | requestAnimationFrame(step) |
| GPU animation | Animate transform and opacity only |
| Lazy load images | IntersectionObserver with rootMargin |
| Animate on scroll | IntersectionObserver with threshold |
| Watch DOM changes | MutationObserver |
| Responsive components | ResizeObserver |
| Large lists | Virtual scrolling โ render only visible rows |