DOM Performance
Why the DOM is Expensive
Every DOM modification can trigger style recalculation, layout, and paint. These are fast individually but catastrophic in loops. Reading layout properties forces the browser to flush pending changes before returning a value.
Layout Thrashing
Interleaving DOM reads and writes in a loop forces layout recalculation on every iteration.
// Bad: read-write cycle forces layout on every iteration
const items = document.querySelectorAll(".item");
items.forEach(item => {
const height = item.offsetHeight; // READ — forces layout
item.style.height = (height * 2) + "px"; // WRITE — invalidates layout
});
// Good: batch all reads, then all writes
const heights = Array.from(items).map(item => item.offsetHeight);
items.forEach((item, i) => {
item.style.height = (heights[i] * 2) + "px";
});
Properties that trigger layout when read:
offsetTop/Left/Width/Height, clientTop/Left/Width/Height
scrollTop/Left/Width/Height, getBoundingClientRect(), getComputedStyle()
DocumentFragment
Build up elements in memory, then insert once.
const list = document.querySelector("#todo-list");
const fragment = document.createDocumentFragment();
const tasks = ["Buy groceries", "Clean kitchen", "Write report"];
tasks.forEach(task => {
const li = document.createElement("li");
li.textContent = task;
fragment.appendChild(li); // in memory, no reflow
});
list.appendChild(fragment); // one DOM insertion
For trusted content, building an HTML string is also efficient:
list.innerHTML = tasks.map(t => `<li>${t}</li>`).join("");
Virtual Scrolling
Rendering thousands of DOM nodes is slow. Virtual scrolling renders only visible items plus a buffer.
function createVirtualList(container, items, itemHeight) {
const visibleCount = Math.ceil(container.clientHeight / itemHeight);
const buffer = 5;
const content = document.createElement("div");
content.style.height = `${items.length * itemHeight}px`;
content.style.position = "relative";
container.appendChild(content);
function render() {
const scrollTop = container.scrollTop;
const start = Math.max(0, Math.floor(scrollTop / itemHeight) - buffer);
const end = Math.min(items.length, start + visibleCount + buffer * 2);
content.innerHTML = "";
for (let i = start; i < end; i++) {
const el = document.createElement("div");
el.textContent = items[i];
el.style.position = "absolute";
el.style.top = `${i * itemHeight}px`;
el.style.height = `${itemHeight}px`;
content.appendChild(el);
}
}
container.addEventListener("scroll", render);
render();
}
// 10,000 items, only ~20 in the DOM at any time
IntersectionObserver
Detect when elements enter the viewport without scroll listeners or getBoundingClientRect.
Lazy Loading Images
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
observer.unobserve(img);
}
});
}, { rootMargin: "200px" });
document.querySelectorAll("img.lazy").forEach(img => observer.observe(img));
Infinite Scroll
const sentinel = document.querySelector("#load-more-trigger");
const observer = new IntersectionObserver(async (entries) => {
if (entries[0].isIntersecting) appendItems(await fetchNextPage());
});
observer.observe(sentinel);
MutationObserver
Watch for DOM changes made by any code, not just yours.
const observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
if (node.nodeType !== Node.ELEMENT_NODE) return;
node.querySelectorAll("[data-datepicker]").forEach(initDatePicker);
if (node.matches("[data-datepicker]")) initDatePicker(node);
});
});
});
observer.observe(document.body, { childList: true, subtree: true });
// Call observer.disconnect() when done
requestAnimationFrame
Schedule work right before the next repaint for smooth animations.
function animateSlideIn(element) {
let start = null;
const duration = 300, distance = 100;
function step(timestamp) {
if (!start) start = timestamp;
const progress = Math.min((timestamp - start) / duration, 1);
element.style.transform = `translateX(${distance * (1 - progress)}px)`;
element.style.opacity = progress;
if (progress < 1) requestAnimationFrame(step);
}
requestAnimationFrame(step);
}
For simple transitions, prefer CSS animations — the browser can optimize them on the compositor thread. Use requestAnimationFrame when you need frame-by-frame control.
requestIdleCallback
Schedule low-priority work during idle periods when the browser has no other work to do.
requestIdleCallback((deadline) => {
while (deadline.timeRemaining() > 0 && pendingWork.length > 0) {
doWork(pendingWork.shift());
}
if (pendingWork.length > 0) {
requestIdleCallback(processRemaining);
}
});
Use requestIdleCallback for analytics, prefetching, or non-urgent DOM updates. Use requestAnimationFrame for visual updates.
When Vanilla DOM is Enough
Vanilla DOM works well for modest interactivity (forms, toggles, tabs, modals), simple localized state, and zero-dependency pages. Consider a framework when many UI parts share frequently changing state, components are deeply nested, or you need efficient list diffing and complex routing. The platform APIs in this topic are the same ones frameworks use internally.
Common Pitfalls
Interleaving DOM reads and writes. Batch reads first, then writes. One interleaved read in a write loop forces a full layout recalculation.
Using scroll listeners for visibility detection. IntersectionObserver does this without triggering layout.
Animating layout properties. width, height, top, left, and margin trigger layout every frame. Animate transform and opacity instead — they run on the compositor thread.
// Bad: triggers layout every frame
element.style.left = x + "px";
// Good: compositor thread
element.style.transform = `translateX(${x}px)`;
Creating thousands of DOM nodes for long lists. Use virtual scrolling.
Forgetting to disconnect observers. Both MutationObserver and IntersectionObserver keep references alive. Call disconnect() or unobserve() when done.
Key Takeaways
- Batch DOM reads and writes separately to avoid layout thrashing.
- Use
DocumentFragmentorinnerHTMLfor bulk insertions. - Virtual scrolling keeps the DOM small regardless of data size.
IntersectionObserverreplaces scroll-based visibility checks with zero layout cost.MutationObserverwatches for DOM changes from any source.requestAnimationFramesyncs animations with the browser's repaint cycle.- Animate
transformandopacity, never layout properties.