DOM Rendering Performance

Written by Backbone Tutorials Team

Last updated: June 2026 · 10 min read

The DOM is both the model of the page and the source of most rendering cost. Every node the browser holds in memory participates in style recalculation, layout, and paint. Write to it carelessly and you trigger those stages repeatedly within a single frame; read from it at the wrong moment and you force layout to run before the browser was ready. Knowing how the DOM interacts with the rendering pipeline, and how to structure operations to minimise the work it triggers, is the most direct path to a fast, smooth page.

This guide builds directly on the rendering lifecycle and connects to the scalable UI patterns for large lists.

DOM update cost by approach Individual node appends cause one reflow each; DocumentFragment batches them into one; virtualization renders only visible rows regardless of list size. N appends N reflows DocumentFragment 1 reflow Virtualization ~20 rows regardless of N Scales with list size Scales with list size Constant cost
Individual appends cause one reflow per node. DocumentFragment batches them into one. Virtualization keeps cost constant regardless of list length.

Why DOM size matters

The DOM is not free storage. Every node the browser keeps alive participates in rendering work whether or not it is visible.

Nodes as cost units

Style recalculation, layout, and paint all scale with the number of nodes they must consider. A DOM with 5 000 nodes runs style recalculation noticeably slower than one with 500, because the browser has to match CSS selectors against every element. Memory consumption rises too, and JavaScript that queries nodes with querySelectorAll or traverses the tree pays proportional to depth. Google's Lighthouse flags total DOM node counts above 1 500 as a warning, above 800 as a recommendation to reduce. The practical guideline is to only keep nodes in the DOM that are currently needed; everything else should either be generated on demand or virtualized.

Batching with DocumentFragment

When you need to insert multiple nodes, how you do it determines whether layout runs once or once per node.

Off-screen assembly before insertion

// Bad: one reflow per append
items.forEach(function (item) {
  var li = document.createElement('li');
  li.textContent = item.name;
  list.appendChild(li);           // triggers reflow each time
});

// Good: one reflow total
var frag = document.createDocumentFragment();
items.forEach(function (item) {
  var li = document.createElement('li');
  li.textContent = item.name;
  frag.appendChild(li);           // no reflow, off-screen
});
list.appendChild(frag);           // one reflow on insertion

A DocumentFragment is an off-screen container that lives outside the live DOM. Appending nodes to it costs nothing in layout terms because nothing visible changed. A single appendChild of the completed fragment inserts all nodes in one operation and causes one reflow. For a list of a hundred items, this is the difference between a hundred layout passes and one. Backbone collection views that render items one at a time into the live container make exactly this mistake; building the fragment first and appending once fixes it.

Read/write ordering

The sequence of DOM reads and writes within a single frame determines whether the browser can batch layout or is forced to run it repeatedly, a pattern explored in the rendering lifecycle guide.

Batch reads, then batch writes

// Forced layout on every iteration (bad)
rows.forEach(function (row) {
  var h = row.offsetHeight;       // read: forces layout
  row.style.height = h * 2 + 'px'; // write: invalidates layout
});

// One layout pass (good)
var heights = rows.map(function (row) {
  return row.offsetHeight;        // all reads first
});
rows.forEach(function (row, i) {
  row.style.height = heights[i] * 2 + 'px'; // all writes after
});

Reading a geometry property like offsetHeight after writing styles forces the browser to flush pending layout immediately to return an accurate value. Interleaving reads and writes in a loop causes one forced layout per iteration. Separating them into a read phase followed by a write phase lets the browser batch the layout to the end of the frame.

Style and class manipulation

How you apply visual changes affects both correctness and the number of style recalculations triggered.

classList and CSS custom properties

// Prefer classList over direct style for toggleable states
el.classList.add('is-active');
el.classList.remove('is-loading');
el.classList.toggle('is-open');

// For dynamic values, CSS custom properties avoid inline style churn
el.style.setProperty('--progress', progress + '%');

Setting individual style properties inline can trigger style recalculation for each assignment if the browser flushes between them. Toggling a class with classList makes one change that the browser processes in the next style pass. For values that change frequently, like an animation progress percentage, setting a CSS custom property and letting the stylesheet consume it keeps the JavaScript side simple and the CSS side expressive. Avoid reading computed styles in a loop; cache the value instead.

List virtualization

No batching strategy helps when the list itself has thousands of rows. For those cases the answer is to stop rendering the invisible ones entirely.

Windowing: render only the viewport

// Simplified virtual list: only render visible rows
var ROW_HEIGHT = 40;
var VISIBLE    = Math.ceil(container.clientHeight / ROW_HEIGHT) + 2;

function renderWindow(scrollTop) {
  var start = Math.floor(scrollTop / ROW_HEIGHT);
  container.style.paddingTop = start * ROW_HEIGHT + 'px';
  container.style.paddingBottom =
    (items.length - start - VISIBLE) * ROW_HEIGHT + 'px';
  var frag = document.createDocumentFragment();
  items.slice(start, start + VISIBLE).forEach(function (item) {
    var row = document.createElement('div');
    row.textContent = item.name;
    frag.appendChild(row);
  });
  container.innerHTML = '';
  container.appendChild(frag);
}

Virtualization renders only the rows that fit in the viewport plus a small buffer. The scrollable height is preserved with padding, so the scrollbar looks correct. Rows outside the window simply do not exist in the DOM, so style, layout, and paint costs stay constant regardless of list length. A list of ten thousand items renders as fast as a list of twenty. Libraries like TanStack Virtual handle the edge cases, but the principle is the same as the code above.

Measuring DOM cost

DOM performance problems are invisible until you measure them. The browser provides the tools to see exactly where the cost is.

DevTools Performance and Memory panels

The Chrome DevTools Performance panel shows style recalculation, layout, and paint as coloured blocks in the flame chart. Long purple blocks indicate expensive style or layout work; if they repeat within a frame, layout thrashing is likely. The Memory panel's heap snapshot shows DOM node counts and detached trees, nodes that are no longer in the document but are still referenced by JavaScript and therefore not garbage collected. Detached trees are a frequent source of memory leaks in long-running Backbone apps where views are removed from the DOM but their event listeners keep a reference alive. Calling stopListening and remove on a Backbone view releases both.

Frequently Asked Questions

Why does a large DOM hurt performance?

A large DOM increases the cost of every layout, style recalculation, and paint pass because the browser must process more nodes. It also uses more memory and slows JavaScript that queries or modifies nodes. Google recommends keeping the total DOM node count below 1500.

What is DocumentFragment and why use it?

DocumentFragment is an off-screen container for building groups of DOM nodes before inserting them. Because it lives outside the live DOM, adding nodes to it does not trigger reflow. A single appendChild of the completed fragment causes one reflow instead of one per node.

What is list virtualization?

List virtualization, also called windowing, renders only the rows visible in the viewport plus a small buffer, regardless of how many total items exist. As the user scrolls, rows are recycled rather than created and destroyed. A list of 10 000 items renders as fast as a list of 20 rows.

Is className or classList faster for toggling styles?

classList methods like add, remove, and toggle are generally preferred because they modify individual classes without replacing the full className string, reducing the risk of accidental overwrites. For toggling a single class repeatedly, both are fast enough that the difference is negligible; the ergonomics and safety of classList make it the better default.

Read next: the Rendering hub, or revisit the rendering lifecycle for the frame-level mechanics these patterns sit inside.

See DOM performance patterns in a real Backbone app.

The Backbone guide shows views and collections built with efficient DOM update discipline.

Explore the Backbone Guide →