---
title: 'The DOM API'
source: 'https://academia.sh/en/courses/browser-platform/dom-api'
course: 'The Browser and the Web Platform'
language: en
updated: '2026-08-17T18:09:11+00:00'
license: 'CC BY-SA 4.0'
---

# The DOM API

The interface that reads and modifies the document tree; the node-versus-element distinction, the difference between a live collection and a static list, the attribute-property duality, and the cost of batch changes.

The Web Fundamentals and HTML course built a document: the North Slope Measurement
Station page, a sectioned structure, a measurement table, and an accessible submission
form. The Visual Presentation with CSS course gave that document style. Across both
courses, the page was **static**: it stood exactly as it arrived from the server, and
nothing the user did changed it.

This course adds behavior to the page. Behavior's first condition is being able to read
and modify the document from inside a program. The Markup Language Concept lesson showed
how the tree is built from the source text; in this lesson, the structure built there
becomes **a data structure with an interface**.

## The Tree Is an Object Graph

Every node the parser produces is an object the program can reach. Objects are linked to
each other through parent, child, and sibling bonds; every point in the tree can be
reached through these bonds.

The first trap is hidden in what these bonds carry. An element's children are not only
elements: the whitespace between tags also enters the tree as a text node. For this
reason, the API offers two separate ways to walk it — one that sees every node, another
that sees only element nodes.

```js
// nodes.mjs — the difference between child nodes and child elements
const text = (value) => ({ type: "text", value, children: [] });
const element = (name, attrs = {}, ...content) => {
  const node = { type: "element", name, attrs, children: [], parent: null };
  for (const part of content) {
    const child = typeof part === "string" ? text(part) : part;
    child.parent = node;
    node.children.push(child);
  }
  return node;
};

// tree equivalent of the measurement list from K01:
// <ul id="measurements">
//   <li class="measurement">Temperature</li>
//   <li class="measurement">Relative humidity</li>
// </ul>
const list = element("ul", { id: "measurements" },
  "\n  ",
  element("li", { class: "measurement" }, "Temperature"),
  "\n  ",
  element("li", { class: "measurement" }, "Relative humidity"),
  "\n");

const childNodes = (d) => d.children;
const childElements = (d) => d.children.filter((c) => c.type === "element");

console.log("child node count   :", childNodes(list).length);
console.log("child element count:", childElements(list).length);
console.log("first child node   :", JSON.stringify(childNodes(list)[0].value));
console.log("first child element:", childElements(list)[0].name);
```

```
child node count   : 5
child element count: 2
first child node   : "\n  "
first child element: li
```

There are three text nodes between the two `li` elements, and all three consist only of a
line break and indentation. In the browser API, this distinction is met by the pairs
`childNodes` versus `children`, `firstChild` versus `firstElementChild`, `nextSibling`
versus `nextElementSibling`. The first column of each pair sees every node, the second
sees only elements.

Code that walks a document starting from the first node can break when indentation in the
markup changes. This is the source of the fragility, and the reason for the
selector-based access covered in the next section.

## Selecting a Node

Instead of walking the tree by hand, the node being looked for is described with a
selector. The syntax defined in the Selectors lesson of the Visual Presentation with CSS
course applies here exactly as it is: the language that determines which elements style
rules apply to also determines which elements a program can reach.

There are two basic operations. One returns the **first** matching element, the other
**all** of them. Both start from a root: a search can be run over the whole document, or
over a specific element's subtree. Searching a subtree excludes the same selector's
matches elsewhere on the page, which is why it is the form preferred by code that runs
inside a component's boundaries.

There is also access by id. Because an id is unique within a document, this access is
read directly from an index rather than scanning the tree; but it rests on the uniqueness
assumption. If the same id is written twice, the first is returned and no error is
raised.

## Live Collection and Static List

What a selection operation returns can be one of two different kinds, and the difference
determines what the code sees after the tree changes.

```js
// collection.mjs — the difference between a live collection and a static list
const element = (name, attrs = {}, ...content) => {
  const node = { type: "element", name, attrs, children: [], parent: null };
  for (const part of content) {
    const child = typeof part === "string" ? { type: "text", value: part, children: [] } : part;
    child.parent = node;
    node.children.push(child);
  }
  return node;
};

// Walks every element in the tree in pre-order.
function* elements(root) {
  if (root.type === "element") yield root;
  for (const child of root.children) yield* elements(child);
}

const matches = (o, selector) => {
  if (selector.startsWith(".")) return (o.attrs.class ?? "").split(/\s+/).includes(selector.slice(1));
  if (selector.startsWith("#")) return o.attrs.id === selector.slice(1);
  return o.name === selector;
};

// Static list: copies the matches at call time into an array.
const selectAll = (root, selector) => [...elements(root)].filter((o) => matches(o, selector));

// Live collection: rescans the tree on every read.
const liveCollection = (root, selector) => ({
  get length() { return selectAll(root, selector).length; },
});

const list = element("ul", { id: "measurements" },
  element("li", { class: "measurement" }, "Temperature"),
  element("li", { class: "measurement" }, "Relative humidity"));

const staticList = selectAll(list, "li");
const live = liveCollection(list, "li");
console.log("before insertion — static:", staticList.length, "live:", live.length);

const item = element("li", { class: "measurement" }, "Wind speed");
item.parent = list;
list.children.push(item);

console.log("after insertion  — static:", staticList.length, "live:", live.length);
```

```
before insertion — static: 2 live: 2
after insertion  — static: 2 live: 3
```

A static list is a copy of the matches at the moment it was called; even if the tree
changes afterward, the list does not change. A live collection, on the other hand, is the
query itself: every time it is read, it gives the matches at that moment.

In the browser API, `querySelectorAll` returns a static result, `getElementsByTagName` and
`getElementsByClassName` a live one. The distinction matters in two situations. First,
adding or removing an element inside the body of a loop iterating over a live collection
changes the collection's length while the loop is running; an infinite loop originates
here. Second, holding onto a static list for a long time keeps nodes removed from the tree
in memory — this is this course's counterpart to the accessibility problem defined in the
Memory Leak Diagnosis lesson of the Asynchronous JavaScript and Runtime course.

## Attribute and Property

An element's `href="..."` declaration written in the markup and its `href` value on the
program side are not the same thing. The first is called the **attribute**, the second
the **property**. The attribute is the string the parser reads from the source text; the
property is a value derived from that string, one that can change at runtime.

```js
// attribute.mjs — the distinction between the attribute string and the property value
const BASE = "https://data.example.test/station/north-slope/";

// Attributes are always strings; properties are resolved, live values.
const PROPERTY = {
  a: (o) => ({ href: new URL(o.attrs.href, BASE).href }),
  "input[text]": (o) => ({
    defaultValue: o.attrs.value,           // read from the attribute
    value: o.enteredValue ?? o.attrs.value, // the value the user has entered
  }),
  "input[checkbox]": (o) => ({
    defaultChecked: "checked" in o.attrs,
    checked: o.marked ?? "checked" in o.attrs,
  }),
};

const elements = [
  ["a", { attrs: { href: "../log?type=temperature" } }],
  ["input[text]", { attrs: { name: "value", value: "-4.2" }, enteredValue: "-6.8" }],
  ["input[checkbox]", { attrs: { name: "verified", checked: "" }, marked: false }],
];

for (const [type, o] of elements) {
  console.log(type);
  console.log("  attribute:", JSON.stringify(o.attrs));
  console.log("  property :", JSON.stringify(PROPERTY[type](o)));
}
```

```
a
  attribute: {"href":"../log?type=temperature"}
  property : {"href":"https://data.example.test/station/log?type=temperature"}
input[text]
  attribute: {"name":"value","value":"-4.2"}
  property : {"defaultValue":"-4.2","value":"-6.8"}
input[checkbox]
  attribute: {"name":"verified","checked":""}
  property : {"defaultChecked":true,"checked":false}
```

Three forms of divergence are visible. On the link, the attribute is a relative address,
while the property is the absolute address resolved against the base address defined in
the Links lesson. On the text field, the attribute carries the **starting** value and
does not change as the user types; the current value lives only in the property. On the
checkbox, the attribute only announces the checked state at page load; when the user
unchecks the box, the attribute stays as it was, the property becomes false.

A practical rule follows from this: **state the user can change is read from the
property, the starting state the document declares from the attribute.** Code that reads
a form field's current value from the attribute will never see what the user typed.

The `data-` attributes defined in the Data Attributes lesson sit outside this duality:
because they have no defined property counterpart, they stay strings in both directions.

## Creating and Placing Nodes

New content can be produced two ways. The first is building the node piece by piece: an
element is created, its attributes are written, a text node is added, then it is placed
into its spot in the tree. The second is giving a markup string and having the parser
build the tree.

The second way is shorter to write and has a cost: if a value coming from the user gets
mixed into the string, the `<` character in that value is interpreted as a tag. When the
escaping rule defined in the Tags, Attributes, and Entities lesson is not enforced here,
data turns into code. For this reason, content carrying user data is added as a text
node; a `<` character written into a text node never becomes a tag under any condition.

Placement operations share a common property: a node can have only one place in the tree.
When a node that is already connected is added somewhere else, it is not copied, it is
**moved**; it comes out of its old place on its own.

## The Cost of Batch Changes

Changing a node connected to the tree invalidates the relevant part of the style and
layout calculation. Adding a hundred-item list one at a time triggers this invalidation a
hundred times. The solution is preparing the nodes in a container not connected to the
document and connecting them all at once. This container is called a **document
fragment**.

```js
// fragment.mjs — the difference between inserting one by one and inserting via a document fragment
const element = (name, attrs = {}) => ({ type: "element", name, attrs, children: [], parent: null });

let invalidations = 0; // every change made in a tree attached to the document increments this

function isConnected(node) {
  let ancestor = node;
  while (ancestor.parent) ancestor = ancestor.parent;
  return ancestor.type === "document";
}

// One call = one tree change.
function append(target, ...nodes) {
  for (const d of nodes) { d.parent = target; target.children.push(d); }
  if (isConnected(target)) invalidations += 1;
}

const document_ = { type: "document", name: "#document", children: [], parent: null };
const list = element("ul", { id: "measurements" });
append(document_, list);

const records = ["Temperature", "Relative humidity", "Wind speed", "Precipitation", "Pressure"];

// First way: every item is added to the connected tree with a separate call.
invalidations = 0;
for (const name of records) append(list, element("li", { "data-measurement": name }));
console.log("direct insertion:", invalidations, "invalidations,", list.children.length, "children");

// Second way: items are first collected in an unconnected fragment, then added with one call.
list.children.length = 0;
invalidations = 0;
const fragment = { type: "fragment", name: "#fragment", children: [], parent: null };
for (const name of records) append(fragment, element("li", { "data-measurement": name }));
append(list, ...fragment.children.splice(0));
console.log("fragment insertion:", invalidations, "invalidations,", list.children.length, "children");
console.log("remaining in fragment:", fragment.children.length, "children");
```

```
direct insertion: 5 invalidations, 5 children
fragment insertion: 1 invalidations, 5 children
remaining in fragment: 0 children
```

Two properties of the document fragment appear in the output. First, because additions
made to the fragment are not connected to the document, they produce no invalidation.
Second, when the fragment is added to the tree, it does not enter the tree itself; it only
hands off its children and empties out. The zero on the last line shows this.

The number here is not a performance measurement, it is a **call count**. How large the
real cost is depends on the document and the style; measuring it is the subject of the
Performance Logging lesson. What is constant is that the number of computations triggered
drops from five to one.

## Summary

- The document tree is an object graph linked by parent-child-sibling bonds; part of
  these bonds sees every node, part sees only elements.
- Indentation and line breaks between tags enter the tree as text nodes; code that walks
  node bonds is sensitive to how the markup is formatted.
- A selection operation returns either a static list or a live collection; a live
  collection re-queries the tree every time it is read.
- The attribute is the string in the source text, the property is a live value derived
  from it; user input only ever appears in the property.
- Every change to a connected node invalidates the style and layout calculation; batch
  insertion is prepared in an unconnected document fragment.

## Next Step

This lesson established reading and modifying the document, but left open what triggers a
change. A page does not change on its own; the moment the user clicks, types, or scrolls
produces a notification, and the program responds to that notification. The path these
notifications follow on the tree is defined and is not a single node: an event passes
through its ancestors before reaching its target, and returns by the same path after the
target. The next lesson examines this three-phase journey and the order listeners are
called in.
