DOM Performance Tips: Making Your UI Feel Faster Without a Framework
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:
- Browser calculates layout to give you
offsetWidth - You change
style.width, invalidating that layout - Next iteration: browser has to recalculate layout again
- 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:
- Build in memory, insert once. Use
DocumentFragmentor build strings withinnerHTML. - Batch reads, then batch writes. Never interleave them.
- One listener on the parent. Not 1000 listeners on children.
- Hide, modify, show. For complex multi-step changes.
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.