Tools Of The Browser

What Is the DOM? Document Object Model Explained

The DOM is the live object tree a browser builds from HTML. JavaScript reads and changes that tree, then the browser updates the page.

12 min read

A screenshot of chrome DevTools showing the DOM.

The DOM, or Document Object Model, is the live object tree a browser builds from HTML. It gives JavaScript a way to read, create, move, and remove parts of a page.

HTML is the source text. The DOM is the browser’s in-memory version of that text after parsing, error correction, script changes, and runtime updates.

<body>
  <h1>Hello</h1>
  <p>World</p>
</body>

becomes a tree:

document
└─ html
   └─ body
      ├─ h1 -> "Hello"
      └─ p  -> "World"

HTML source vs DOM tree

The DOM can differ from the original HTML file. Browsers fix missing tags, normalize structure, and apply changes made by scripts.

This HTML is incomplete:

<table>
  <tr><td>One
</table>

The browser still builds a valid DOM tree from it. It inserts the missing pieces required for the table model.

JavaScript changes the DOM after parsing:

const heading = document.querySelector("h1");
heading.textContent = "Hello DOM";

The source file on the server has not changed. The DOM in the current tab has.

Nodes and elements

Everything in the DOM is a node. Elements are one kind of node.

Node typeExampleMeaning
Documentdocumentroot object for the page
Element<main>an HTML element
TextHellotext inside an element
Comment<!-- note -->HTML comment
DocumentFragmenttemporary treeoff-page container for building nodes

An element such as <button> is also a JavaScript object. It inherits from HTMLElement and exposes properties such as classList, dataset, style, and methods such as addEventListener().

const button = document.querySelector("button");

button instanceof HTMLElement; // true
button instanceof Node; // true

Finding elements

Most DOM work starts by selecting an element.

const form = document.querySelector("form");
const buttons = document.querySelectorAll("button[data-action]");

querySelector() returns the first match. querySelectorAll() returns a static NodeList of all matches at that moment.

Older methods still exist:

document.getElementById("search");
document.getElementsByClassName("card");
document.getElementsByTagName("a");

Some older methods return live collections, which update as the DOM changes. That behavior can surprise code that expects a fixed list. Current DOM code often uses querySelectorAll() for predictable snapshots.

Moving through the tree

After selecting a node, you can move to nearby nodes:

const item = document.querySelector(".active");

item.parentElement;
item.previousElementSibling;
item.nextElementSibling;
item.children;
item.closest("nav");

Use element-specific properties when you want HTML elements. childNodes includes text and comment nodes, including whitespace text nodes created by line breaks in the HTML.

That distinction explains a common surprise:

list.firstChild; // may be a whitespace text node
list.firstElementChild; // first child element

Creating and changing nodes

The DOM is mutable. JavaScript can create nodes and insert them into the page.

const message = document.createElement("p");
message.textContent = "Saved";

document.querySelector("main").append(message);

You control where a node lands. append() and prepend() add inside an element, at the end or the start. before() and after() add as a sibling. To remove a node, call remove() on it directly.

const list = document.querySelector("ul");
list.prepend(firstItem);   // becomes the first child
list.after(footnote);      // becomes the next sibling of the list
oldItem.remove();          // detaches oldItem from the tree

Changing text is different from changing HTML:

message.textContent = userInput;
message.innerHTML = userInput;

textContent treats the value as text. innerHTML parses it as markup. Use textContent for user-provided text unless you have a trusted sanitizer and a clear reason to accept HTML. insertAdjacentHTML() parses a markup string at a precise position when you do need HTML from a trusted source.

Attributes vs properties

Every element carries two related layers of information, and they can drift apart.

Attributes come from the HTML source. The browser reads them while parsing and uses them as the initial state.

const input = document.querySelector("input"); // <input value="Hello">
input.getAttribute("value"); // "Hello"

Properties live on the JavaScript object and hold the current state.

input.value = "Goodbye"; // the field on screen updates
input.getAttribute("value"); // still "Hello"

The attribute is the starting value. The property is what the element is right now. When you update a field and the page does not change, you are often reading or writing the wrong one. Change the property to change what the user sees.

Boolean attributes follow the same rule. Presence alone means true, and toggling the property does not rewrite the markup.

checkbox.checked = false; // unchecks the box
checkbox.hasAttribute("checked"); // still true if it was in the HTML

Custom data-* attributes are the clean way to attach metadata. The browser exposes them through dataset.

const button = document.querySelector("button"); // <button data-state="ready">
button.dataset.state; // "ready"
button.dataset.state = "clicked"; // updates the data-state attribute

Events move through the DOM

When a user clicks a button, the browser dispatches an event through the DOM tree.

The event has phases:

  1. capture: from window down toward the target
  2. target: on the clicked element
  3. bubble: back up through ancestors

You can hook either direction. A normal listener fires during bubbling; pass { capture: true } to listen on the way down. event.stopPropagation() halts the travel, and event.preventDefault() cancels the browser’s default action without stopping propagation.

Because events bubble, one listener can serve many elements:

document.querySelector("ul").addEventListener("click", (event) => {
  const button = event.target.closest("button[data-id]");
  if (!button) return;

  console.log(button.dataset.id);
});

One listener on the list can handle clicks from buttons added later because the click bubbles up from the target. This is event delegation, and it keeps you from attaching a listener to every row.

Custom events

Built-in events cover clicks, keys, and forms. For your own signals, such as cart:add or modal:open, the DOM provides CustomEvent and dispatchEvent().

button.dispatchEvent(new CustomEvent("cart:add", {
  detail: { productId: 42, qty: 1 },
  bubbles: true,
  cancelable: true,
}));

A listener reads the payload from event.detail and otherwise treats it like any other event:

document.addEventListener("cart:add", (event) => {
  console.log(event.detail.productId);
});

cancelable: true lets a listener veto the action with preventDefault(), in which case dispatchEvent() returns false. When the event starts inside a shadow root, add composed: true so it can cross the boundary and reach document-level listeners. Group names with a prefix, such as modal:open and modal:close, to keep handlers easy to find.

The DOM lifecycle

The DOM is built as the browser reads the HTML, so a script can run before the elements it needs exist. Three events mark the timeline:

  • DOMContentLoaded fires when the HTML is parsed and the full tree exists. Stylesheets and images may still be loading.
  • load fires when everything is in, including images, fonts, and iframes.
  • beforeunload fires just before the user leaves, which is the moment to save state.
document.addEventListener("DOMContentLoaded", () => {
  // every element is now selectable
});

DOMContentLoaded is the one most code waits for. Script placement decides whether you even need it. A plain <script> in the <head> pauses parsing while it downloads and runs. Two attributes fix that:

  • defer downloads in parallel and runs after the DOM is parsed, in order.
  • async runs as soon as it downloads, regardless of parse order.
<script src="app.js" defer></script>

A deferred script already sees a complete DOM, so it often removes the need for a DOMContentLoaded listener.

DOM changes and rendering cost

DOM changes can trigger style recalculation, layout, paint, and compositing. The browser does not repaint the whole page for every small change, but layout-affecting changes can become expensive when repeated in a loop.

This pattern can cause repeated layout work:

for (const item of items) {
  item.style.width = box.offsetWidth + "px";
}

The read of offsetWidth can force the browser to flush layout after writes. A safer pattern is to read measurements first, then write changes in a separate step.

Inspecting the DOM

DevTools shows the live DOM, not only the original HTML. If JavaScript adds a class, inserts a modal, or removes a node, the Elements panel reflects that runtime state.

Use the console to connect what you see with code:

$0 // selected element in DevTools
$0.getBoundingClientRect()
$0.closest("section")

The DOM is the contract between the parsed document, user interaction, and JavaScript. Learn the tree, and browser behavior becomes easier to debug.

Read More From Our Blog

Read the blog →

Explore Our Tools

Browse all tools