I once had to optimize a page that rendered a table with 2,000 rows. The original code appended each row individually in a loop. On a decent laptop, it froze the browser for almost 3 seconds. On a lower-end machine, it was completely unusable.

After applying the techniques in this article, the same table rendered in under 100ms. Same data, same visual result—just smarter DOM manipulation.

Modern frameworks handle a lot of this automatically with virtual DOMs and batched updates. But if you're writing vanilla JavaScript, or just want to understand why your React app is still slow sometimes, this stuff matters.

The Cost of Touching the DOM

There's a useful mental model: JavaScript runs on a fast island, and the DOM lives on a slow island across a bridge. Every time you access the DOM—read a property, write a style, append a child—you're crossing that bridge. The goal is to minimize crossings.

Two operations are especially expensive:

Repaint: The browser redraws pixels. Happens when you change colors, visibility, shadows. Noticeable but usually manageable.

Reflow (Layout): The browser recalculates the position and size of elements. Happens when you change dimensions, margins, fonts, or add/remove elements. This is the killer. Changing one element's width might force the browser to recalculate the layout of everything on the page.

Batch Your DOM Updates

Here's the naive way to build a list:

// ❌ Slow: 1000 reflows
const list = document.getElementById('my-list');
const items = generateItems(); // Returns 1000 items

items.forEach(text => {
    const li = document.createElement('li');
    li.textContent = text;
    list.appendChild(li);  // Reflow!
});

Every appendChild triggers a reflow. The browser recalculates layout 1000 times.

The fix is to build everything in memory first, then add it to the DOM in one shot:

// ✅ Fast: 1 reflow
const list = document.getElementById('my-list');
const fragment = document.createDocumentFragment();

items.forEach(text => {
    const li = document.createElement('li');
    li.textContent = text;
    fragment.appendChild(li);  // This is free, it's just memory
});

list.appendChild(fragment);  // One reflow for everything

DocumentFragment is basically a temporary container that doesn't exist in the actual DOM. You can build your whole structure inside it, then insert it all at once.

For that 2000-row table I mentioned, this single change cut render time from 3 seconds to about 200ms.

Avoid Layout Thrashing

This one is sneaky. It happens when you alternate between reading and writing DOM properties:

// ❌ Layout thrashing
const boxes = document.querySelectorAll('.box');

boxes.forEach(box => {
    const width = box.offsetWidth;   // READ - forces layout calculation
    box.style.width = (width + 10) + 'px';  // WRITE - invalidates layout
});

Here's what's happening in that loop:

  1. Browser calculates layout to give you offsetWidth
  2. You change style.width, invalidating that layout
  3. Next iteration: browser has to recalculate layout again
  4. Repeat 1000 times

The fix is dead simple—separate your reads from your writes:

// ✅ No thrashing
const boxes = document.querySelectorAll('.box');
const widths = [];

// Phase 1: Read everything
boxes.forEach(box => {
    widths.push(box.offsetWidth);
});

// Phase 2: Write everything
boxes.forEach((box, i) => {
    box.style.width = (widths[i] + 10) + 'px';
});

Layout is calculated once for all reads, then invalidated once after all writes. Two layout calculations instead of 2000.

Event Delegation

If you have a list of 500 items, do you really need 500 event listeners?

// ❌ 500 listeners, 500 functions in memory
document.querySelectorAll('li').forEach(li => {
    li.addEventListener('click', handleClick);
});

Events bubble up through the DOM. You can catch them on a parent element:

// ✅ 1 listener, handles all children
document.getElementById('my-list').addEventListener('click', (e) => {
    if (e.target.tagName === 'LI') {
        handleClick(e);
    }
});

This isn't just about memory. It's also about dynamic content. With delegation, new items automatically work—you don't need to attach listeners every time you add something to the list.

The display:none Trick

Sometimes you need to make a lot of changes to a single element—change its dimensions, update its classes, move things around inside it. Each change could trigger a reflow.

Old-school trick: hide the element first.

const element = document.getElementById('complex-widget');

// 1. Take it out of the rendering tree
element.style.display = 'none';  // 1 reflow

// 2. Make all your changes (no reflows, element is hidden)
element.style.width = '500px';
element.style.height = '300px';
element.className = 'widget active expanded';
element.innerHTML = buildComplexContent();

// 3. Put it back
element.style.display = 'block';  // 1 reflow

Hidden elements don't participate in layout calculations. So all those changes in the middle are essentially free. You pay for 2 reflows instead of potentially dozens.

Quick Reference

Here's what I keep in mind when writing DOM-heavy code:

None of this is rocket science. It's just understanding what the browser is doing under the hood. Once you see the DOM as that slow island across a bridge, the optimizations become obvious.

← Back to JavaScript Articles

Back to Home