# DOM

## What it is

The DOM, short for Document Object Model, is the tree of objects a browser
builds from a page and keeps in memory for as long as the page is open. The HTML
parser reads the markup, and its output is a document object: every element,
every run of text and every comment becomes a node, and each node knows its
parent, its children and its neighbours. Scripts read and change the page
through that tree, and what the browser draws is worked out from it.

The WHATWG maintains the model in the DOM Standard, whose own summary names
three things it covers without tying them to any one platform: node trees,
events, and the aborting of activities in progress. The name is older than the
standard. In its original sense it meant an interface for reaching into a
document and changing it; today most people also say “the DOM” for the tree
itself, and both meanings are in daily use. The HTML Standard builds on top of
it: HTML defines the elements and what they do, the DOM Standard defines what a
node, an element, an attribute and an event are.

The tree is not the source file. A page's HTML is text that arrived over the
network. The DOM is what the parser made of that text, with every parse error
handled by rules the HTML Standard spells out, and then changed by every script
that has run since. The parser keeps details that are easy to forget. The
whitespace between two tags can become a text node of its own, so the first
child of an element is not always the first element inside it, although the
standard's own example notes that not every stretch of whitespace turns into a
text node.

Nodes come in a handful of kinds. The document sits at the root, with a doctype,
elements, text nodes and comments beneath it. A document fragment is a light
container that sits outside the page's tree, which makes it a handy place to
assemble a block before inserting it in one move. An element can also host a
shadow tree, a separate tree of its own that the document's ordinary queries do
not reach into.

Lists of nodes come in two kinds, and the difference matters. The standard's
rule is that a collection is live unless stated otherwise: `childNodes` and the
HTMLCollection from `getElementsByClassName()` change as the tree changes.
`querySelectorAll()` is the stated exception and returns a static list, a
snapshot taken at the moment of the call.

The same standard defines how events travel. An event is dispatched at a target
node and passes through three phases: capturing, on the way down from the top of
the tree through the target's ancestors; at the target itself; and bubbling,
back up through the ancestors. The `eventPhase` property reports where it is,
as 1, 2 or 3, and 0 when the event is not being dispatched. A listener added
with `addEventListener()` takes options: `once` removes it after its first call,
`passive` promises that it will not call `preventDefault()`, and `signal` ties
it to an `AbortController`, so that aborting the controller removes the
listener.

## Why it matters

Every script that changes a page works through the DOM, whatever language or
framework it was written in. A framework adds a layer of its own on top, but
what the reader finally sees is nodes in this tree. Next.js describes hydration
as React attaching event handlers to the DOM, which is how HTML sent from the
server becomes a page that responds to clicks. A team that knows the tree
underneath can debug what the framework actually did, rather than what it was
asked to do.

The tree is also where text and markup part ways. A string assigned to
`textContent` replaces an element's children with a single text node and is
never read as markup. The same string assigned to `innerHTML` is handed to the
HTML parser, and the HTML Standard notes that those setters remove nothing
dangerous by default. Which of the two a page uses decides whether a visitor's
display name is shown as text or run as code.

What a reader experiences depends on more than the tree. Styles decide whether
a node is visible, where it sits and whether a pointer can reach it, and none of
that is visible to a tool that only walks the nodes. That gap is where automated
checks are most often wrong, and it is the reason to test a page as rendered as
well as the document behind it.

Finally, the DOM is the contract between a page and everything that reads it
after the server has finished. Tests that click through a page, browser
extensions and assistive software all start from the live tree. The other side
of that contract is easy to miss: a crawler that does not run JavaScript sees
only the HTML that was sent, and none of the nodes that scripts add later.

## What it looks like in practice

The script below, written for this article, fills a list of orders from data,
keeps a single click listener on the list rather than one per item, and can
remove that listener later with one call. `openOrder` stands for whatever the
page does when an order is chosen.

```js
const list = document.querySelector("#orders");
const controller = new AbortController();

function render(orders) {
  const items = orders.map((order) => {
    const li = document.createElement("li");
    li.dataset.id = order.id;
    li.textContent = `${order.number}: ${order.status}`;
    return li;
  });
  list.replaceChildren(...items);
}

list.addEventListener(
  "click",
  (event) => {
    const li = event.target.closest("li");
    if (li) openOrder(li.dataset.id);
  },
  { signal: controller.signal },
);

// When the view goes away:
controller.abort();
```

Each part maps onto the standard. `createElement()` makes an element that
belongs to no parent until it is inserted. `textContent` puts the order number
in as text, so a status that happens to contain angle brackets stays a status.
`replaceChildren()` removes the old items and inserts the new ones in a single
operation. The one listener on the list serves every item because a click on an
item bubbles up to the list, and `closest()` walks from whatever was clicked to
the nearest `li`. Aborting the controller removes the listener without the page
having to keep a reference to the function.

The tree also explains a class of defects that this site's own review turned up.
In
[an accessibility review carried out in August 2026](/portfolio/the-accessibility-faults-your-checker-misses/),
the site passed its automated rules and still had eleven defects, every one of
them down to how the page rendered rather than to what its markup said. Eight
footer links stayed in the tab order behind `pointer-events`, a property that
removes pointer interaction and nothing else. To anything reading the document
they were ordinary links; a pointer never reached them, and the keyboard still
stopped on every one. A scroll-driven animation clipped the footer off three
pages. Of the four rules the review left behind, one reads as a warning against
trusting the tree alone: visibility has to be established from the rendered
pixels, not merely from the document tree.

## Where it goes wrong

**A loop over a live collection skips every other item.** The HTMLCollection
from `getElementsByClassName()` is live, so a loop that removes the class from
each element by index shrinks the collection under itself and steps over every
second element. Iterate a static list instead: `querySelectorAll()` returns one,
and `Array.from()` turns a live collection into a plain array first.

```js
const pending = document.getElementsByClassName("pending");
for (let i = 0; i < pending.length; i++) {
  pending[i].classList.remove("pending"); // skips every other one
}

for (const el of document.querySelectorAll(".pending")) {
  el.classList.remove("pending"); // static list: all of them
}
```

**Listeners pile up.** A view that adds its listeners every time it renders, and
never removes them, runs each handler once per render: after three visits, one
click sends three requests. Removing a listener by hand takes the very same
function object that was added, and an arrow function written inline at the call
leaves nothing to pass back. Pass a `signal` from one `AbortController` per
view, as in the example above, and a single `abort()` removes every listener the
view added; use `once: true` for a listener that should fire only one time.

**Text goes in as markup.** Building a string of HTML from data and assigning it
to `innerHTML` gives the data to the parser, with no sanitizing by default. A
display name that carries markup, an image tag with an error handler for
instance, arrives on the page as a real element with its handler attached. Text
belongs in `textContent` or in a text node made with `createTextNode()`. Where
markup really must come from a string, the HTML Standard defines `setHTML()`,
which sanitizes as it parses and removes unsafe content.

**An observer feeds itself.** A `MutationObserver` reports changes to the nodes
it watches, and its callback runs after each batch of changes. A callback that
writes to the subtree it observes, to add a counter or correct a class, creates
new records and is called again, and the page can lock up in a loop of its own
making. Watch only what has to be watched, with `attributeFilter` and without
`subtree` where possible, have the callback check whether its change is already
in place before writing, or call `disconnect()` before the write and
`observe()` again after it.

## Further reading

- [DOM Standard](https://dom.spec.whatwg.org/) — the WHATWG's definition of
  nodes, trees, live and static collections, events, `AbortController` and
  `MutationObserver`.
- [Parsing HTML documents](https://html.spec.whatwg.org/multipage/parsing.html)
  — how the HTML parser turns a stream of characters into a document object,
  parse errors included.
- [Dynamic markup insertion](https://html.spec.whatwg.org/multipage/dynamic-markup-insertion.html)
  — `innerHTML`, `setHTML()` and `setHTMLUnsafe()`, and which of them
  sanitize.
- [Server and Client Components](https://nextjs.org/docs/app/getting-started/server-and-client-components)
  — the Next.js account of hydration: HTML from the server, then event handlers
  attached to the DOM.

<https://engineer.company/glossary/dom/>
