The DOM Explained: How JavaScript Controls a Web Page

The DOM (Document Object Model) is the live, tree-shaped representation of an HTML document that the browser builds in memory. JavaScript reads and modifies that tree through the document object, and every change you make appears on screen. This guide covers how the tree is built, how to select and change nodes, how events travel through it, and how to avoid the performance traps that slow real applications.

Quick Takeaways

  • The DOM is an in-memory tree of nodes, not the HTML source text. JavaScript changes the tree, and the browser re-renders.
  • Use querySelector() and querySelectorAll() for selection, textContent for safe text updates, and createElement() for building nodes.
  • Events move through three phases: capturing, target, and bubbling. Event delegation uses bubbling to handle many children with one listener.
  • Batch writes with DocumentFragment and avoid layout thrashing to keep updates fast.
Concept What It Is Typical API
Node Any item in the tree node.nodeType
Element A node created from an HTML tag document.querySelector()
Attribute Metadata on an element getAttribute(), setAttribute()
Event A signal like click or input addEventListener()
Render tree DOM + CSSOM, used for painting Browser internal

What Is the DOM?

The browser downloads your HTML as plain text. It then parses that text into a tree of objects. Each tag becomes an Element node. Each run of text becomes a Text node. The whole tree hangs off a root called document.

Take this HTML:

<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Demo</title>
  </head>
  <body>
    <h1 id="title">Hello</h1>
    <ul class="list">
      <li>One</li>
      <li>Two</li>
    </ul>
  </body>
</html>

The browser builds this structure:

document
└── html
    ├── head
    │   └── title
    │       └── "Demo" (text)
    └── body
        ├── h1#title
        │   └── "Hello" (text)
        └── ul.list
            ├── li → "One"
            └── li → "Two"

The DOM is not the source HTML. If a script appends a new <li>, the DOM changes but the original file does not. If the HTML has a malformed tag, the parser fixes it in the tree. “View Source” shows the text. DevTools Elements panel shows the DOM.

Node Types You Will Meet

Every node has a numeric nodeType. Three cover most daily work.

Node Type nodeType Example
Element 1 <div>, <p>
Text 3 The words inside a <p>
Comment 8 <!-- note -->
Document 9 The document object

How the Browser Builds the Page

The browser follows a fixed pipeline:

  1. Parse HTML into the DOM.
  2. Parse CSS into the CSSOM (CSS Object Model).
  3. Combine both into the render tree, which holds only visible nodes.
  4. Layout calculates the size and position of each box.
  5. Paint draws pixels.

A <script> tag without async or defer blocks parsing. The browser stops building the DOM, runs the script, then continues. That is why scripts that touch the DOM belong at the end of <body> or carry the defer attribute.

<!-- Runs after the DOM is fully parsed, in document order -->
<script src="app.js" defer></script>

With defer, the script downloads in parallel and executes after parsing completes. Your selectors will find their elements.

Selecting Elements

You cannot change a node you cannot find. Modern JavaScript offers two main selection styles.

querySelector and querySelectorAll

querySelector() returns the first match or null. querySelectorAll() returns a static NodeList of all matches. Both accept any CSS selector.

// Select a single element by ID
const title = document.querySelector('#title');

// Select the first element matching a class
const list = document.querySelector('.list');

// Select all list items inside the list
const items = document.querySelectorAll('.list li');

console.log(title.textContent); // "Hello"
console.log(items.length);      // 2

The output shows the text of the heading and the number of matched <li> nodes. If no element matches #title, title is null, and accessing .textContent throws a TypeError. Guard against that:

const banner = document.querySelector('#banner');
if (banner) {
  banner.hidden = false;
}

// Optional chaining avoids the explicit check for reads
console.log(banner?.textContent);

getElementById and Friends

Older methods remain valid and are slightly faster for simple lookups.

const title = document.getElementById('title');
const links = document.getElementsByTagName('a');   // live HTMLCollection
const cards = document.getElementsByClassName('card'); // live HTMLCollection

The key difference is liveness. getElementsByClassName() returns a live collection that updates when the DOM changes. querySelectorAll() returns a static snapshot.

Method Returns Live? Accepts CSS Selector? Speed
getElementById() Element or null N/A No (ID only) Fastest
getElementsByClassName() HTMLCollection Yes No Fast
querySelector() Element or null N/A Yes Fast
querySelectorAll() NodeList No Yes Fast

Use querySelector() as your default. Reach for getElementById() in hot paths.

Traversing the Tree

Once you hold one node, you can walk to its relatives.

const list = document.querySelector('.list');

console.log(list.parentElement.tagName);      // "BODY"
console.log(list.children.length);            // 2
console.log(list.firstElementChild.textContent); // "One"
console.log(list.lastElementChild.textContent);  // "Two"

const first = list.firstElementChild;
console.log(first.nextElementSibling.textContent); // "Two"

// Find the closest ancestor matching a selector
console.log(first.closest('ul').className);   // "list"

Prefer the ...Element... properties (children, firstElementChild) over childNodes and firstChild. The latter include whitespace Text nodes, which surprises beginners.

Changing Content, Attributes, and Styles

Text and HTML

Three properties set content. They differ in speed and safety.

Property Parses HTML? Reads Hidden Text? XSS Risk
textContent No Yes None
innerText No No (CSS-aware, triggers layout) None
innerHTML Yes Yes High with untrusted input
const title = document.querySelector('#title');

// Safe: treats the string as plain text
title.textContent = 'Welcome back';

// Parses markup. Only use with trusted strings.
title.innerHTML = 'Welcome <em>back</em>';

The first assignment replaces the heading text. The second renders “back” in italics.

Attributes and Classes

const link = document.querySelector('a');

link.setAttribute('href', 'https://example.com');
link.setAttribute('target', '_blank');
console.log(link.getAttribute('href')); // "https://example.com"
link.removeAttribute('target');

// Use classList instead of editing className strings
const card = document.querySelector('.card');
card.classList.add('is-active');
card.classList.remove('is-hidden');
card.classList.toggle('expanded');         // add if absent, remove if present
console.log(card.classList.contains('is-active')); // true

// Custom data attributes map to the dataset object
card.dataset.userId = '42';                // sets data-user-id="42"
console.log(card.dataset.userId);          // "42"

classList avoids string concatenation bugs. dataset converts data-user-id into camelCase userId automatically.

Inline Styles vs. Classes

// Works, but scatters styling logic in JavaScript
card.style.backgroundColor = 'tomato';
card.style.setProperty('--accent', '#0a7');

// Preferred: change state with a class and keep styles in CSS
card.classList.add('is-error');

Toggle classes for state changes. Reserve element.style for values computed at runtime, such as a drag position.

Creating, Inserting, and Removing Nodes

Build new elements in memory, then attach them.

function createTodo(text) {
  const li = document.createElement('li');
  li.textContent = text;            // safe text assignment
  li.classList.add('todo');
  return li;
}

const list = document.querySelector('.list');

list.append(createTodo('Write tests'));        // add at end
list.prepend(createTodo('Plan sprint'));       // add at start

const reference = list.children[1];
reference.before(createTodo('Review PR'));     // insert before a node
reference.after(createTodo('Deploy'));         // insert after a node

reference.remove();                            // delete a node

Each call modifies the live tree and the page updates. append() accepts multiple nodes and strings. remove() detaches the node, but any variable still referencing it keeps it alive in memory.

Cloning and Replacing

const template = document.querySelector('.todo');
const copy = template.cloneNode(true); // true = deep clone, includes children

copy.textContent = 'Cloned item';
list.replaceChildren(copy);            // replace all children with one node

cloneNode(true) copies descendants but not event listeners added with addEventListener().

The HTML <template> Element

For repeated markup, a <template> is cleaner than string building.

<template id="card-tpl">
  <article class="card">
    <h3 class="card__title"></h3>
    <p class="card__body"></p>
  </article>
</template>
function renderCard({ title, body }) {
  const tpl = document.querySelector('#card-tpl');
  const node = tpl.content.firstElementChild.cloneNode(true);

  node.querySelector('.card__title').textContent = title;
  node.querySelector('.card__body').textContent = body;
  return node;
}

document.body.append(renderCard({ title: 'DOM', body: 'A live tree.' }));

The template content is inert. Images do not load and scripts do not run until you clone and insert it.

Events: How the DOM Reacts to Users

The browser fires an event when something happens: a click, a keystroke, a form submit, a network response. You register a listener with addEventListener().

const button = document.querySelector('#save');

function handleClick(event) {
  console.log('Clicked:', event.target.id); // "save"
  console.log(event.type);                  // "click"
}

button.addEventListener('click', handleClick);

// Remove it later using the same function reference
button.removeEventListener('click', handleClick);

The handler receives an Event object. event.target is the element that triggered the event. event.currentTarget is the element the listener is attached to.

The Three Phases of Event Flow

An event does not simply fire on one element. It travels the tree in three phases:

  1. Capturing: from window down to the target’s parent.
  2. Target: at the element itself.
  3. Bubbling: from the target back up to window.

By default, listeners run during bubbling. Pass { capture: true } to run during capturing.

const outer = document.querySelector('.outer');
const inner = document.querySelector('.inner');

outer.addEventListener('click', () => console.log('outer (bubble)'));
inner.addEventListener('click', () => console.log('inner (bubble)'));
outer.addEventListener('click', () => console.log('outer (capture)'), { capture: true });

// Clicking .inner logs:
// outer (capture)
// inner (bubble)
// outer (bubble)

Capture listeners fire first, top-down. Bubble listeners fire next, bottom-up.

Controlling Events

Method Effect
event.preventDefault() Cancels the default action (form submit, link navigation)
event.stopPropagation() Stops the event from moving to other nodes
event.stopImmediatePropagation() Also blocks other listeners on the same node
document.querySelector('form').addEventListener('submit', (event) => {
  event.preventDefault(); // stop the page reload
  const data = new FormData(event.target);
  console.log(Object.fromEntries(data));
});

Avoid stopPropagation() unless required. It breaks analytics scripts and delegated handlers elsewhere in the tree.

Event Delegation

Attaching one listener per list item wastes memory and misses items added later. Attach one listener to the parent instead. Bubbling carries child events up to it.

const list = document.querySelector('.list');

list.addEventListener('click', (event) => {
  const item = event.target.closest('li');
  if (!item || !list.contains(item)) return;

  item.classList.toggle('done');
});

// Items added later work automatically
const newItem = document.createElement('li');
newItem.textContent = 'Added dynamically';
list.append(newItem);

Clicking any <li>, including the one appended afterward, toggles the done class. This pattern handles thousands of rows with a single listener.

DOM Performance and Rendering Costs

Reading and writing the DOM is cheap. Forcing the browser to recalculate layout repeatedly is not.

Reflow and Repaint

Operation Cost Triggered By
Reflow (layout) High Changing size, position, font, adding or removing nodes
Repaint Medium Changing color, background, visibility
Composite Low Changing transform, opacity

Animate transform and opacity whenever you can. They skip layout and paint on modern browsers.

Layout Thrashing

Layout thrashing happens when you interleave reads and writes. Each read of a geometry property forces the browser to flush pending writes and recalculate layout.

// Bad: read, write, read, write forces layout on every iteration
const boxes = document.querySelectorAll('.box');
boxes.forEach((box) => {
  const width = box.offsetWidth;       // forces layout
  box.style.width = `${width + 10}px`; // invalidates layout
});
// Good: batch all reads first, then all writes
const widths = Array.from(boxes, (box) => box.offsetWidth);

boxes.forEach((box, i) => {
  box.style.width = `${widths[i] + 10}px`;
});

The refactored version triggers layout once instead of once per element. For n elements, the cost drops from O(n) layouts to O(1).

Batch Inserts with DocumentFragment

Appending nodes one at a time to a live element can trigger repeated reflows. A DocumentFragment is an off-screen container. Insert it once.

const list = document.querySelector('.list');
const fragment = document.createDocumentFragment();

for (let i = 1; i <= 1000; i++) {
  const li = document.createElement('li');
  li.textContent = `Item ${i}`;
  fragment.append(li);   // off-screen, no layout cost
}

list.append(fragment);   // one insertion, one reflow

The loop builds 1,000 nodes in memory. A single append() call moves them all into the page.

Scheduling Visual Work

Use requestAnimationFrame() for visual updates so they run right before the next paint.

function moveBox(box, x) {
  requestAnimationFrame(() => {
    box.style.transform = `translateX(${x}px)`;
  });
}

For observing changes without polling, use the built-in observers.

API Detects Replaces
IntersectionObserver Element enters or leaves viewport Scroll listeners with getBoundingClientRect()
MutationObserver DOM tree or attribute changes Polling loops
ResizeObserver Element size changes window.resize plus measuring
const observer = new IntersectionObserver((entries) => {
  entries.forEach((entry) => {
    if (entry.isIntersecting) {
      entry.target.classList.add('visible');
      observer.unobserve(entry.target); // stop watching after first reveal
    }
  });
}, { threshold: 0.1 });

document.querySelectorAll('.lazy').forEach((el) => observer.observe(el));

Each .lazy element gets the visible class the first time 10% of it enters the viewport. No scroll handler runs.

Direct DOM vs. Virtual DOM vs. Compiled Frameworks

Frameworks exist because manual DOM updates grow hard to maintain. They take different strategies.

Approach Example Update Strategy Memory Overhead Learning Curve
Direct DOM Vanilla JS You mutate nodes yourself Lowest Low to start, high at scale
Virtual DOM React, Vue Diff a JS tree, patch real DOM Medium Medium
Compiled / signals Svelte, Solid Compile to targeted DOM updates Low Medium

The virtual DOM is a plain JavaScript object tree. The framework compares the old and new trees, then applies only the differences. This does not make it faster than hand-tuned vanilla code. It makes correct updates easier to write.

Learn the real DOM first. Every framework compiles down to the same createElement(), setAttribute(), and append() calls.

Best Practices: Anti-Patterns vs. Clean Code

Example 1: Building Markup with Untrusted Input

// Bad: XSS vulnerability. A name like <img src=x onerror=alert(1)> executes.
function renderUser(name) {
  document.querySelector('#user').innerHTML = `<p>Hello, ${name}</p>`;
}
// Good: textContent never parses markup
function renderUser(name) {
  const p = document.createElement('p');
  p.textContent = `Hello, ${name}`;
  document.querySelector('#user').replaceChildren(p);
}

The second version prints the malicious string as harmless text. If you must render user-supplied HTML, sanitize it with a vetted library such as DOMPurify first.

Example 2: Listeners on Every Child

// Bad: 500 listeners, and new rows get none
document.querySelectorAll('.row').forEach((row) => {
  row.addEventListener('click', () => row.classList.toggle('selected'));
});
// Good: one delegated listener covers current and future rows
document.querySelector('.table').addEventListener('click', (event) => {
  const row = event.target.closest('.row');
  if (row) row.classList.toggle('selected');
});

Example 3: Repeated Selection Inside Loops

// Bad: queries the DOM on every iteration
for (let i = 0; i < 100; i++) {
  document.querySelector('#output').textContent += i + ',';
}
// Good: build the string once, write once
const parts = [];
for (let i = 0; i < 100; i++) parts.push(i);
document.querySelector('#output').textContent = parts.join(',');

The bad version performs 100 lookups and 100 text writes. The good version performs one of each.

Example 4: Leaking Listeners

// Bad: listener on window is never removed when the component is destroyed
function mountWidget() {
  window.addEventListener('resize', () => console.log('resized'));
}
// Good: AbortController removes every listener registered with its signal
function mountWidget() {
  const controller = new AbortController();

  window.addEventListener('resize', () => console.log('resized'), {
    signal: controller.signal,
  });

  return () => controller.abort(); // call to clean up
}

const unmount = mountWidget();
unmount(); // listener removed

Passing a signal lets you remove many listeners with one abort() call.

Edge Cases and Gotchas

Script runs before the DOM exists. If your script sits in <head> without defer, querySelector() returns null. Use defer, or wait for the event:

document.addEventListener('DOMContentLoaded', () => {
  console.log('DOM ready, images may still be loading');
});

DOMContentLoaded vs. load. DOMContentLoaded fires when HTML is parsed. window.load fires after images, stylesheets, and subframes finish.

Static vs. live collections. Looping over a live HTMLCollection while removing items skips elements. Convert it first:

const items = Array.from(document.getElementsByClassName('item'));
items.forEach((item) => item.remove()); // safe: iterating a static array

NodeList is not an Array. querySelectorAll() returns a NodeList, which supports forEach() but not map() or filter(). Use Array.from(nodeList) or spread syntax [...nodeList].

Whitespace text nodes. childNodes includes line breaks between tags. Use children for elements only.

Shadow DOM. Web Components can encapsulate a subtree. document.querySelector() does not pierce it. Query through the host’s shadowRoot instead.

Frequently Asked Questions

What is the difference between the DOM and HTML?

HTML is the text file you write. The DOM is the live object tree the browser creates from it. JavaScript can change the DOM at runtime without touching the HTML file, and the browser may also correct invalid HTML when building the tree.

Is the DOM part of JavaScript?

No. The DOM is a Web API defined by the W3C and WHATWG and implemented by browsers. JavaScript is the language that calls it. Node.js has no DOM by default. Libraries like jsdom emulate one for testing.

What is the difference between innerHTML and textContent?

innerHTML parses its value as HTML and can execute injected markup, which creates XSS risk with untrusted input. textContent inserts the value as literal text and is both safer and faster. Use textContent unless you need to render markup.

Why is manipulating the DOM slow?

DOM operations are not slow on their own. The cost comes from reflow and repaint. Changing geometry forces the browser to recompute layout for affected nodes. Batch your writes, separate reads from writes, and use DocumentFragment to minimize layout passes.

Hot this week

Robotics Predictions for 2027

Seven data-driven robotics predictions for 2027: VLA policies, humanoid actuators, ROS 2 real-time, safety filters, and code. Read the engineering roadmap.

Robots and Jobs: What the Evidence Says in 2026

Robots and jobs in 2026: IFR installation data, wage and employment estimates, and a payback model engineers can run. Read the evidence.

Robotics Careers in 2026: Roles, Skills, and Salaries

Explore robotics careers in 2026: top roles, must-have ROS 2 and controls skills, and U.S. salary ranges. Build your robotics career roadmap today.

Robot Sensors Explained: How Robots Perceive the World

Learn how LiDAR, IMU, encoders, and depth cameras work, with fusion math and ROS2 code. Build a reliable perception stack today.

20 Beginner Robot Projects Ranked by Difficulty

Build 20 beginner robot projects ranked from easiest to hardest, with wiring, code, and ROS2 steps. Pick your first build and start today.

Topics

Robotics Predictions for 2027

Seven data-driven robotics predictions for 2027: VLA policies, humanoid actuators, ROS 2 real-time, safety filters, and code. Read the engineering roadmap.

Robots and Jobs: What the Evidence Says in 2026

Robots and jobs in 2026: IFR installation data, wage and employment estimates, and a payback model engineers can run. Read the evidence.

Robotics Careers in 2026: Roles, Skills, and Salaries

Explore robotics careers in 2026: top roles, must-have ROS 2 and controls skills, and U.S. salary ranges. Build your robotics career roadmap today.

Robot Sensors Explained: How Robots Perceive the World

Learn how LiDAR, IMU, encoders, and depth cameras work, with fusion math and ROS2 code. Build a reliable perception stack today.

20 Beginner Robot Projects Ranked by Difficulty

Build 20 beginner robot projects ranked from easiest to hardest, with wiring, code, and ROS2 steps. Pick your first build and start today.

Robot Computers in 2026: Raspberry Pi 5, Jetson, and Industrial PCs Compared

Raspberry Pi 5 vs NVIDIA Jetson vs industrial PCs for ROS 2 robots. See specs, latency math and a decision matrix, then pick your compute.

AI in Robotics: Programmed Behavior vs Machine Learning (and When You Need Each)

Programmed control or machine learning? Compare PID, FSMs, and RL with ROS2 code, benchmarks, and a decision framework. Pick the right one.

What Is ROS 2? A Beginner’s Guide to the Robot Operating System in 2026

Learn what ROS 2 is, how nodes, topics, and DDS work, and build your first publisher and subscriber. Start your ROS 2 project today.

Related Articles

Popular Categories