DOM Performance — Complete Guide
In this tutorial, you will learn about DOM Performance. We cover key concepts, practical examples, and best practices to help you master this topic.
DOM performance optimization involves minimizing reflows, batching reads and writes, using document fragments, and avoiding layout thrashing for smooth 60fps UIs.
What You'll Learn
- The difference between reflow and repaint and what triggers each
- How to batch DOM reads and writes to avoid layout thrashing
- Techniques for minimizing DOM access in hot paths
- How to use DevTools Performance panel to identify DOM bottlenecks
Why It Matters
DOM operations are among the most expensive operations in browser JavaScript. A single layout thrashing loop can cause jank, dropped frames, and a poor user experience. Optimizing DOM access is essential for performant web applications.
Real-World Use
- A data grid rendering 10,000 cells uses virtualization to limit DOM nodes
- An animation library batches style reads before writes to prevent thrashing
- A search-as-you-type input debounces DOM updates to match 60fps
flowchart LR
A[DOM Operation] --> B{Read or Write?}
B -->|Read| C[Schedule read batch]
B -->|Write| D[Schedule write batch]
C --> E[requestAnimationFrame]
D --> E
E --> F[Execute all reads]
F --> G[Execute all writes]
G --> H[Single reflow]
Reflow vs Repaint
Understanding what triggers layout recalculations is the first step to optimization.
// PROPERTIES THAT TRIGGER REFLOW (layout recalculation):
// Reading these forces the browser to compute layout:
// offsetTop, offsetLeft, offsetWidth, offsetHeight
// scrollTop, scrollLeft, scrollWidth, scrollHeight
// clientTop, clientLeft, clientWidth, clientHeight
// getComputedStyle()
// getBoundingClientRect()
// PROPERTIES THAT TRIGGER REPAINT ONLY (no layout):
// color, backgroundColor, visibility, outline
// backgroundImage, borderRadius, boxShadow
// CHANGES THAT TRIGGER REFLOW (writing):
// Changing width, height, margin, padding
// Changing font-size, font-family
// Changing display (none to block)
// Adding or removing elements
// Changing class names that affect layout
const box = document.querySelector('.perf-box');
// BAD: triggers reflow on every iteration
for (let i = 0; i < 100; i++) {
box.style.width = i + 'px'; // Write (queued)
console.log(box.offsetWidth); // Read (forces reflow)
}
// GOOD: batch reads and writes separately
for (let i = 0; i < 100; i++) {
box.style.width = i + 'px'; // All writes queued
}
console.log(box.offsetWidth); // Single read (one reflow)
Expected output: The bad example triggers 100 reflows (one per iteration). The good example triggers 1 reflow. The performance difference grows with the number of iterations.
Layout Thrashing
Layout thrashing happens when you alternate reads and writes, forcing the browser to recalculate layout repeatedly.
// LAYOUT THRASHING EXAMPLE
const items = document.querySelectorAll('.dynamic-item');
// BAD: thrashing loop
for (let i = 0; i < items.length; i++) {
const width = items[i].offsetWidth; // Read (forces reflow)
items[i].style.width = (width * 2) + 'px'; // Write (invalidates layout)
// Next iteration reads again before browser can batch
}
// GOOD: batch reads first
const widths = [];
for (let i = 0; i < items.length; i++) {
widths.push(items[i].offsetWidth); // All reads (one reflow)
}
for (let i = 0; i < items.length; i++) {
items[i].style.width = (widths[i] * 2) + 'px'; // All writes (one reflow)
}
console.log('Layout thrashing avoided');
Expected output: The good approach takes roughly half the time because it triggers only 2 reflows instead of 2n reflows. For 1000 items, this is the difference between 5ms and 200ms.
Using requestAnimationFrame
Align DOM changes with the browser's rendering cycle.
const animated = document.querySelector('.animated');
let position = 0;
// BAD: setTimeout for animation (not synced with vsync)
// setInterval(() => {
// position += 1;
// animated.style.transform = `translateX(${position}px)`;
// }, 16);
// GOOD: requestAnimationFrame (synced with display refresh)
let startTime = null;
function animate(timestamp) {
if (!startTime) startTime = timestamp;
const elapsed = timestamp - startTime;
position = (elapsed / 16) % 500; // Move 500px over ~8 seconds
animated.style.transform = `translateX(${position}px)`;
requestAnimationFrame(animate);
}
// Start the animation
requestAnimationFrame(animate);
// requestAnimationFrame pauses when tab is hidden
// (saves CPU and battery)
console.log('Animation started with rAF');
Expected output: The element animates smoothly at 60fps. The animation pauses when the tab is hidden, saving resources. setTimeout-based animations would continue running in the background.
Minimizing DOM Access
Cache DOM references and avoid repeated queries.
// BAD: repeated DOM queries
function updateBad() {
document.querySelector('.counter').textContent = count++;
document.querySelector('.counter').style.color = 'blue';
document.querySelector('.counter').className = 'updated';
}
// GOOD: cache the reference
const counterEl = document.querySelector('.counter');
function updateGood() {
counterEl.textContent = count++;
counterEl.style.color = 'blue';
counterEl.className = 'updated';
}
// BAD: repeated length access in loops
for (let i = 0; i < document.querySelectorAll('.item').length; i++) {
// querySelectorAll creates a new collection each time!
// Even worse, length is re-evaluated on each iteration
}
// GOOD: cache the collection and length
const allItems = document.querySelectorAll('.item');
for (let i = 0; i < allItems.length; i++) {
// allItems is cached, length is still read each time
}
// BEST: cache length too
for (let i = 0, len = allItems.length; i < len; i++) {
// Length read once
}
Expected output: The cached versions are significantly faster, especially in loops with many iterations or frequently called functions.
DOM Node Limits
The browser has limits on how many DOM nodes it can handle smoothly.
// The browser recommendation is under 1500 DOM nodes.
// Heavy pages struggle above 3000 nodes.
// Check DOM size
const totalElements = document.querySelectorAll('*').length;
console.log(`Total DOM elements: ${totalElements}`);
// If you need to render many items, use one of these:
// 1. Virtual scrolling (render only visible items)
// 2. Pagination (render in pages)
// 3. Windowing (reuse DOM nodes)
// BAD: rendering 10,000 items directly
const list = document.querySelector('#big-list');
for (let i = 0; i < 10000; i++) {
const item = document.createElement('div');
item.textContent = `Item ${i}`;
list.appendChild(item);
// This creates 10,000 DOM nodes!
}
// BETTER: use pagination or windowing
// Only render what is visible, recycle nodes as user scrolls
console.log('DOM node count after render:', document.querySelectorAll('*').length);
Expected output: Rendering 10,000 items directly creates 10,000+ DOM nodes, causing slow scrolling and high memory usage. Virtual scrolling maintains a constant small number of nodes.
Using DevTools Performance Panel
Identify DOM performance issues with browser DevTools.
// Step 1: Open Chrome DevTools > Performance tab
// Step 2: Click Record
// Step 3: Perform the action you want to measure
// Step 4: Stop recording
// Look for:
// - Purple bars (rendering) that take > 16ms
// - Red triangles indicating forced reflow/layout
// - Long yellow bars (JavaScript execution)
// - Layout shifts with high scores
// Performance Markers
performance.mark('dom-start');
// ... do DOM operations ...
performance.mark('dom-end');
performance.measure('DOM Operation', 'dom-start', 'dom-end');
const measures = performance.getEntriesByType('measure');
measures.forEach(m => {
console.log(`${m.name}: ${m.duration.toFixed(2)}ms`);
});
// Clear marks to avoid memory leaks
performance.clearMarks();
performance.clearMeasures();
Expected output: The Performance panel highlights which operations are slow. The performance.mark calls provide custom timing data in the DevTools timeline.
Common Mistakes
- Reading offsetWidth/Height in a write loop — Each read forces the browser to flush the write queue and recalculate layout. Batch reads and writes separately.
- Using innerHTML += to append items — Each concatenation serializes the entire content, concatenates, and re-parses. Use DOM methods or build the string once.
- Not detaching elements before batch operations — For massive changes, remove the container from the DOM, modify it, then reattach. This limits reflows to two.
- Forgetting to cancel requestAnimationFrame — Always save the rAF ID and cancel it when the component unmounts or the animation is no longer needed.
- Creating thousands of event listeners — Use event delegation instead of attaching listeners to each element. One listener on a parent handles all children.
Practice Questions
- What triggers a reflow versus a repaint? Reflow is triggered by changes to layout properties (width, height, position). Repaint is triggered by visual changes that do not affect layout (color, background).
- What is layout thrashing? Alternating between reading and writing layout properties in a loop, forcing the browser to invalidate and recalculate layout on every iteration.
- How does requestAnimationFrame improve animation performance? It synchronizes JavaScript execution with the browser's rendering cycle (typically 60fps), avoiding extra frames and reducing CPU usage. It also pauses when the tab is hidden.
- Challenge: Profile a DOM-heavy operation on a page you use. Use the Performance panel to identify the slowest operation. Apply one optimization technique and re-profile to measure the improvement.
FAQ
Mini Project
Create a performance benchmark page. Build a function that adds 1000 paragraphs to the DOM using four different methods: innerHTML concatenation, innerHTML with built string, createElement in loop, and DocumentFragment. Measure and display the time for each method. Also test batching reads before writes vs thrashing reads/writes. Show all results in a table on the page.
What's Next
Continue with Lesson 24: Fragment Batching to learn advanced techniques for batching DOM operations efficiently.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro