Home / Articles / Live DOM Collections, Layout Thrashing and the Attribute Gap Explained

This article is published in English.

Live DOM Collections, Layout Thrashing and the Attribute Gap Explained

Learn why the DOM is a live rendered tree, how that causes skipped loop items and forced layouts, and why custom data attributes need getAttribute.

1407 words

The DOM has a reputation for being slow, and the usual explanation is that it is simply a heavy structure. That explanation is mostly wrong. DOM operations are reasonably fast; what hurts is a common coding pattern that alternates between asking the DOM for information and telling it to change, again and again inside a loop. To see why, you first need an accurate picture of what the DOM is, and that same picture explains two other behaviors that regularly confuse developers: loops that skip elements, and custom attributes that come back undefined.

The DOM is a live tree, not a copy of your HTML

When the browser parses HTML, it does not keep the markup as text. It creates an object for each element and nests those objects according to the markup, producing a tree. Consider a small product catalog:

<div id="catalog">
  <div class="product">
    <h3>Desk Lamp</h3>
    <span class="price">$34</span>
  </div>
  <div class="product">
    <h3>Standing Mat</h3>
    <span class="price">$58</span>
  </div>
</div>

From this, the browser builds a tree of live objects that JavaScript can reach: #catalog holds two .product elements, and each of those holds an <h3> and a <span>. The key point is that this tree is not a description generated once and then set aside. It is the structure the browser is rendering at this moment. Modify a property on one of those node objects and the page reflects it; there is no separate save step or render call.

document.querySelector(".product h3").textContent = "Desk Lamp (Sale)";

This statement is not a request that the browser will process later on your behalf. The assignment itself is the change. Keeping that "live" quality in mind is the most useful mental model you can have for the DOM, and it is also the source of a bug that nearly everyone writes at some point.

Why a loop over a live collection skips elements

Suppose you want to delete every product card marked as out of stock. The obvious loop looks like this:

const outOfStockCards = document.getElementsByClassName("out-of-stock");

for (let i = 0; i < outOfStockCards.length; i++) {
  outOfStockCards[i].remove();
}

It appears correct, yet it silently leaves every second match behind. The reason is that getElementsByClassName does not give you a fixed array. It returns a live HTMLCollection that reflects the document as it changes. As soon as .remove() takes out the first card, the collection is one item shorter and every remaining card moves down one index. The loop counter still advances, so the element that just slid into index 0 is never visited. The collection is changing size underneath the loop, and roughly half the matches escape.

The fix is to take a static copy before you start mutating:

const outOfStockCards = Array.from(document.getElementsByClassName("out-of-stock"));

for (const card of outOfStockCards) {
  card.remove();
}

Array.from snapshots the live collection into an ordinary array, so later removals cannot shrink what you are iterating. A simpler option is document.querySelectorAll(".out-of-stock"), which returns a static NodeList from the start and needs no conversion. That predictability is one reason querySelectorAll has mostly pushed the older lookup APIs out of current codebases. If you must work with a live collection, iterating backwards from the last index also avoids the shift, though a static snapshot is usually clearer.

Layout thrashing: the real reason DOM code feels slow

Now to the actual performance problem. Calculating layout, meaning the precise size and position of every element, is expensive. Browsers avoid doing it more than necessary: when you change styles, they do not recompute layout right away. They collect pending changes and calculate layout once, shortly before the next frame is painted.

That strategy only works as long as your code lets it. Some properties and methods, including offsetWidth, offsetHeight and getBoundingClientRect(), need up-to-date geometry to return a value. Reading them forces the browser to perform layout synchronously, because it cannot report an element's width without calculating it.

The following loop interleaves a read and a write for every card:

// forces a full layout recalculation on every single iteration
const cards = document.querySelectorAll(".product");
cards.forEach((card) => {
  const width = card.offsetWidth; // read: forces layout
  card.style.width = width + 10 + "px"; // write: invalidates layout again
});

Each iteration reads a width and then writes a new one. The read cannot be answered from stale data, because the style change queued on the previous iteration might affect the result. So the browser flushes the pending changes, recomputes layout, returns the number, and then the very next line invalidates that layout again. This cycle is known as layout thrashing (or forced synchronous layout), and it is what people are really experiencing when they say the DOM is slow. The DOM is doing expensive work far more often than necessary because the code keeps demanding it.

Separate the work into two passes, all reads first and all writes afterwards:

const cards = document.querySelectorAll(".product");
const widths = Array.from(cards).map((card) => card.offsetWidth); // all reads, together
cards.forEach((card, i) => {
  card.style.width = widths[i] + 10 + "px"; // all writes, together
});

Now layout is calculated once, when the first width is read, and the remaining reads use that same result because nothing has been changed in between. The writes then queue up and are handled together before the next paint. The DOM did not become faster; the code simply stopped forcing it to redo the same calculation on every iteration.

A few practical notes follow from this:

  • The same rule applies to other geometry reads such as clientWidth, scrollTop and getComputedStyle().
  • In larger applications, reads and writes often happen in different functions or components, so thrashing can occur even when no single loop looks suspicious. Browser performance tools highlight forced layouts, which makes them easier to track down.
  • Scheduling writes with requestAnimationFrame is a common way to keep them grouped just before rendering.

Properties and attributes are not always the same thing

A final surprise comes from the relationship between HTML attributes and JavaScript properties. They usually mirror each other, but the mirroring has limits, and those limits matter as soon as you add custom data. Take this element:

<div class="product" data-sku="LAMP-2201"></div>

And this code that reads from it in three different ways:

const el = document.querySelector(".product");
console.log(el.className); // "product" — standard attributes map to properties directly
console.log(el.sku);       // undefined — custom attributes don't
console.log(el.getAttribute("data-sku")); // "LAMP-2201" — this is how you actually reach it

Standard attributes that the browser knows about, such as href, src and class, are exposed as matching properties automatically. class appears as className because class was a reserved word in JavaScript. Custom attributes do not get that treatment, including those prefixed with data-, the standard mechanism for storing your own metadata on elements. There is no el.sku property, so the lookup returns undefined, and you need getAttribute and setAttribute to read or change the value. As an additional convenience, browsers also expose data- attributes through the dataset object, so el.dataset.sku returns the same value. The inconsistency is small, but it is exactly what produces a puzzling undefined the first time you expect a custom attribute to behave like href.

One mental model instead of three gotchas

These behaviors are not separate pieces of trivia. They all follow from a single fact: the DOM is a live structure being rendered, rather than inert data that you populate one time.

  • Live collections change while you loop over them, so snapshot them or use querySelectorAll before mutating.
  • Reading geometry forces the browser to compute layout immediately, so batch reads before writes.
  • JavaScript properties sit on top of attributes as a convenience and do not mirror all of them, so reach custom data through getAttribute or dataset.

Once you think of the DOM as a live tree rather than a slow data structure, these stop being surprises and become predictable consequences. For a broader view of how rendering proceeds from state changes to pixels in a framework context, see how React turns state updates into screen pixels.