Tags: web-dev concept

The DOM

Date: 2026-08-16


A tree of objects the browser built from your HTML, and then kept changing. It is not your source file — it’s a live structure that diverges from the markup the moment anything runs, which is why “view source” and DevTools disagree.


What it is

The DOM — Document Object Model — is the browser’s in-memory representation of the document as a tree of nodes, exposed to JavaScript as objects you can read and mutate.

<html>                          Document
  <body>                          └─ html
    <h1>Boots</h1>                     └─ body
    <ul>                                    ├─ h1
      <li>Size 8</li>                       │    └─ #text "Boots"
    </ul>                                   └─ ul
  </body>                                        └─ li
</html>                                               └─ #text "Size 8"

Every element, attribute and run of text is a node. The tree is what CSS is matched against, what gets laid out, and what JavaScript queries.

It is not your HTML

The most important thing to internalise, and the source of a whole class of confusion:

  • The parser fixes your markup. Unclosed tags get closed, <td> outside a <table> gets moved, invalid nesting is repaired. The tree may not match what you wrote — see Document Parsing
  • Scripts mutate it immediately. Any framework, any tag manager, any third-party widget
  • view-source: shows the original bytes; DevTools shows the current tree. When they disagree, both are correct and they’re showing different things

This is why a test that “doesn’t apply” often has applied and then been overwritten, and why DOM-scraping analytics breaks silently — see The Data Layer.

Live and static collections

A genuine footgun, because two similar-looking APIs behave differently.

const live   = document.getElementsByClassName('item');  // HTMLCollection — live
const static = document.querySelectorAll('.item');       // NodeList — static
 
console.log(live.length, static.length);   // 3  3
 
document.querySelector('.item').remove();
 
console.log(live.length, static.length);   // 2  3   ← live updated itself

A live collection re-queries the document whenever you touch it. Iterating one while removing items skips elements, because the collection shrinks underneath the loop. querySelectorAll returns a static snapshot and is the safer default.

Why DOM work is expensive

The DOM is not slow in itself. What’s expensive is that touching it can force the browser to recompute layout — see Reflow and Repaint.

The specific pattern that ruins performance is layout thrashing: interleaving reads and writes so each read forces the browser to flush pending changes.

// BAD — forces layout on every iteration
items.forEach(el => {
  el.style.width = el.offsetWidth + 10 + 'px';   // read then write, ×n
});
 
// BETTER — batch reads, then batch writes
const widths = items.map(el => el.offsetWidth);          // all reads
items.forEach((el, i) => el.style.width = widths[i] + 10 + 'px');  // all writes

Properties that force a synchronous layout when read: offsetWidth, offsetHeight, offsetTop, getBoundingClientRect(), scrollTop, getComputedStyle().

Practical points

  • textContent over innerHTML for text. innerHTML parses a string as markup, which is slower and is an injection surface — see Common Vulnerabilities
  • Build detached, then attach. Constructing a subtree in a DocumentFragment and inserting once costs one layout instead of n
  • Element count matters. Tens of thousands of nodes slows style recalculation and memory regardless of what’s visible. Virtualise long lists
  • Detached nodes leak. A removed element still referenced by a variable or a closure stays in memory with its whole subtree — Memory and Long Sessions
  • data- attributes are the sanctioned place to attach information to an element, readable via dataset

Shadow DOM

A separate, encapsulated tree attached to an element, whose styles and structure don’t leak in either direction. It’s how <video> controls have existed all along, and it’s what Web Components use.

Two consequences worth knowing before you meet them: querySelector from the outside won’t find inside a shadow root, and page-level CSS doesn’t apply within it. Both are the point, and both surprise people debugging a third-party widget.