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 type | Example | Meaning |
|---|---|---|
Document | document | root object for the page |
Element | <main> | an HTML element |
Text | Hello | text inside an element |
Comment | <!-- note --> | HTML comment |
DocumentFragment | temporary tree | off-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:
- capture: from
windowdown toward the target - target: on the clicked element
- 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:
DOMContentLoadedfires when the HTML is parsed and the full tree exists. Stylesheets and images may still be loading.loadfires when everything is in, including images, fonts, and iframes.beforeunloadfires 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:
deferdownloads in parallel and runs after the DOM is parsed, in order.asyncruns 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.





