DOM Performance and Intersection Observer

โ–ถ Try It Yourself

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
Note: Animations using 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.
Tip: 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.
Warning: Layout thrashing โ€” reading a layout property immediately after writing โ€” forces the browser to synchronously complete the pending layout before returning the value. Interleaving reads and writes in a loop can cause a reflow for every iteration. Always batch all reads first, then batch all writes, to avoid forced synchronous layouts.

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

▶ Try It Yourself

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

🧠 Test Yourself

Which CSS property should you animate to ensure 60fps without triggering layout recalculation?





โ–ถ Try It Yourself