Web Platform · DOM and Events

Node Identity and Structural Mutation in the DOM Tree

A DOM node is an object with one parent slot, and every insertion is a removal followed by an attach, with range adjustment, observer records, and adoption steps folded into that single call.

appendChild returns its argument. That return value is the first clue that the DOM is not a container into which objects are copied: the node handed to the method is the same object that afterwards sits in the tree, and if it already sat somewhere else it is now gone from that place. Programs that treat the DOM as a data structure to be rebuilt from scratch fight this design constantly. Programs that treat it as a mutable graph of objects with unique identity get predictable behaviour from it.

One parent slot per node

The tree is defined by five pointers per node: parentNode, firstChild, lastChild, previousSibling, and nextSibling. Each is a single slot, but the parent link is unique in the other direction: a node can have many children and exactly one parent. That guarantee is enforced by the mutation algorithms rather than by convention. The document itself is a node with no parent, and document.documentElement is its single element child.

Node kinds are distinguished by the numeric nodeType, and the values are fixed by the standard: 1 element, 3 text, 8 comment, 9 document, 11 document fragment. The same Node interface exposes all of them, so appendChild accepts a text node exactly as readily as a <div>. This is why whitespace in markup produces real children: indentation between two elements becomes a text node with nodeType 3, occupying an index in childNodes and shifting every sibling index after it. Code that walks children by index and assumes elements only is correct against minified markup and wrong against formatted markup.

childNodes is a NodeList whose contents are computed from the pointer chain, not stored in an array. Whether that matters for cost is the subject of live collections and static snapshots; structurally, it means the collection reflects the tree at the moment a property is read.

Insertion removes before it attaches

insertBefore(node, referenceChild) and its special cases appendChild and replaceChild all funnel into one pre-insertion algorithm. It runs in a fixed order, and each step explains an observable behaviour.

First, validity. The algorithm rejects insertions that would create a cycle, place a node where the parent type forbids it, or violate document-level constraints such as a second element child on document or a doctype after the root element. Violations throw HierarchyRequestError, a DOMException. The cycle check walks the ancestor chain of the prospective parent, so it is proportional to the depth of the insertion point.

Second, the special case that surprises people: if the inserted node is a DocumentFragment, its children are inserted in order and the fragment is left empty. A fragment is a staging container with no parent of its own; appending one to an element is the standard way to attach a batch of nodes in a single operation. Each child still goes through the same per-node steps, but the caller performs one call and the browser can defer some work until the batch completes.

Third, removal from the old position. If the node already has a parent, adoption removes it first, which queues the disconnected reaction for a custom element, and then attaches it at the new position. When the reference child is the node itself, the algorithm substitutes the node’s next sibling as the reference point, so the node lands back where it was. The detach and reattach still happen, so a move triggers disconnectedCallback then connectedCallback, as described in custom element reactions.

Fourth, the attach and its side effects. The pointer updates are constant time. On top of them, the algorithm queues a mutation record, adjusts every live range whose boundary points sit in the affected region, invalidates cached collection state, and enqueues any custom element reactions caused by the change. A single appendChild can therefore be cheap in pointer arithmetic and expensive in bookkeeping, and the bookkeeping cost scales with the number of live ranges and observers rather than with subtree size.

const list = document.querySelector('ul');
const first = list.firstElementChild;

list.appendChild(first);      // the same node, now last
console.log(first.parentNode === list); // true

const fragment = document.createDocumentFragment();
for (const label of ['one', 'two', 'three']) {
  const item = document.createElement('li');
  item.textContent = label;   // a text node child, nodeType 3
  fragment.appendChild(item); // item leaves the fragment
}
list.prepend(fragment);
console.log(fragment.childNodes.length); // 0

Adoption rewires the owner document

Every node has an ownerDocument, and it is not allowed to differ from its parent’s. Inserting a node whose owner document differs triggers the adoption steps: the node and all of its descendants get the new owner document, and each custom element among them receives adoptedCallback. The walk covers the shadow-including subtree, so nodes inside an attached shadow root are adopted too. document.importNode(node, true) performs the clone-and-adopt path explicitly, and document.adoptNode(node) detaches a node from its old document without cloning it.

The practical consequence is that cross-document insertion is proportional to the size of the subtree, while same-document insertion is not. A loop that moves thousands of nodes between two documents pays for the full subtree each time; importing once into a fragment first amortises the traversal.

Removal leaves a detached subtree intact

removeChild returns the node it removed, and that node keeps all of its descendants, its attributes, and its listeners. Detachment is a pointer change on the parent, not a teardown: isConnected becomes false for the removed node and everything below it, while parentNode becomes null only for the root of the removed subtree. Descendants keep pointing at their parent inside the detached fragment of tree.

That design makes detach-and-reinsert a cheap way to move work off the document: build a subtree while it is detached, then attach it in one operation. It also makes leaks easy, because a detached subtree stays alive as long as anything references it, including a closure captured by a listener on one of its nodes. Removing an element from the document therefore does not remove its handlers; explicit removal, or an abort signal tied to the component, is required.

replaceChildren(...nodes) combines removal and insertion into one operation and is the direct way to empty and refill a container. It accepts strings, which are converted to text nodes, and it enqueues a single child-list change for the container rather than one per removed child.

Position comparison is a bitmask

compareDocumentPosition returns a bitmask rather than an ordering constant, because two nodes in different trees have no meaningful order. The bits are DOCUMENT_POSITION_DISCONNECTED (0x01), PRECEDING (0x02), FOLLOWING (0x04), CONTAINS (0x08), CONTAINED_BY (0x10), and IMPLEMENTATION_SPECIFIC (0x20). The last bit is the honest one: when the two nodes are disconnected, the browser still has to return some direction, and the standard permits it to be arbitrary, which is why the flag exists. Code that sorts nodes by comparing positions must filter on the disconnected bit first, or it will produce an order that changes between engines and between runs.

The same ancestor walk underlies contains, which answers the containment question in time proportional to the depth of the tree, not its size. Deeply nested documents make these checks slower; wide ones do not.

Text is a node, and normalisation is a merge

Because character data lives in text nodes, editing a paragraph’s text has two very different shapes. Assigning textContent replaces the children with a single text node: constant work plus the cost of discarding the old subtree. Assigning nodeValue on an existing text node mutates a CharacterData node in place, which is cheaper and produces a characterData mutation record instead of a childList record.

normalize() collapses adjacent text nodes into the first of each run and removes empty ones. The merge is not free of consequences: it changes offsets inside those nodes, so any live range boundary pointing into a merged node is remapped, and code holding a reference to a removed text node now holds a detached object whose parentNode is null. Splitting works the other way: Text.splitText(offset) divides a node at a UTF-16 code unit offset, and offsets are counted in UTF-16 code units rather than code points, so a character outside the Basic Multilingual Plane consumes two units.

What a mutation invalidates

The tree itself is only one of several structures derived from it. A structural change can invalidate a cached live collection, adjust live ranges, schedule observer callbacks, enqueue custom element reactions, and mark style and layout for recomputation. Some of that work is synchronous inside the call, some is deferred to a microtask checkpoint, and some waits for the next rendering update. None of it is cancelled by removing the node again in the same task, which is why bulk replacement of a subtree is cheaper as one replaceChildren call than as a loop of individual removals interleaved with insertions.

Reads are not free either, but they fail differently. offsetWidth and getBoundingClientRect force the browser to have an up-to-date layout, so a read after a write can trigger synchronous layout in the middle of a loop. The mutation algorithms themselves never do this; the cost appears at the first geometry read, which is why batching writes before reads changes the cost profile without changing the DOM operations.

References

  1. DOM Standard — WHATWG
  2. Document Object Model (DOM) Level 1 Specification — W3C
  3. JavaScript: The Definitive Guide — David Flanagan, O'Reilly Media