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()andquerySelectorAll()for selection,textContentfor safe text updates, andcreateElement()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
DocumentFragmentand 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:
- Parse HTML into the DOM.
- Parse CSS into the CSSOM (CSS Object Model).
- Combine both into the render tree, which holds only visible nodes.
- Layout calculates the size and position of each box.
- 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:
- Capturing: from
windowdown to the target’s parent. - Target: at the element itself.
- 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.




