How does JavaScript change what you see on a webpage? How do you click a button and see new content appear, or type in a form and watch suggestions pop up? How does a "dark mode" toggle instantly transform an entire page?
// The DOM lets you do things like this:
document.querySelector('h1').textContent = 'Hello, DOM!'
document.body.style.backgroundColor = 'lightblue'
document.getElementById('btn').addEventListener('click', handleClick)The Document Object Model (DOM) is the bridge between your HTML and JavaScript. It lets you read, modify, and respond to changes in web page content. With the DOM, you can use methods like querySelector() to find elements, getElementById() to grab specific nodes, and addEventListener() to respond to user interactions.
What you'll learn in this guide:
- What the DOM is in JavaScript and how it differs from HTML
- How to select DOM elements (getElementById vs querySelector)
- How to traverse the DOM tree (parent, children, siblings)
- How to manipulate DOM elements (create, modify, remove)
- The difference between properties and attributes
- How the browser turns DOM → pixels (the Critical Rendering Path)
- Performance best practices (avoid layout thrashing!)
What is the DOM in JavaScript?
The Document Object Model (DOM) is a programming interface that represents HTML documents as a tree of objects. As specified by the WHATWG DOM Living Standard, when a browser loads a webpage, it parses the HTML and creates the DOM, a live, structured representation that JavaScript can read and modify. Every element, attribute, and piece of text becomes a node in this tree. In short: the DOM is how JavaScript "sees" and changes a webpage.
How the DOM Tree Structure Works
Think of the DOM like a family tree. At the top sits document (the family historian who knows everyone). Below it is <html> (the matriarch), which has two children: <head> and <body>. Each of these has their own children, grandchildren, and so on.
THE DOM FAMILY TREE
┌──────────┐
│ document │ ← The family historian
│ (root) │ (knows everyone!)
└────┬─────┘
│
┌────┴─────┐
│ <html> │ ← Great-grandma
└────┬─────┘ (the matriarch)
┌─────────────┴─────────────┐
│ │
┌────┴────┐ ┌────┴────┐
│ <head> │ │ <body> │ ← The two branches
└────┬────┘ └────┬────┘ of the family
│ │
┌──────┴──────┐ ┌──────────┼──────────┐
│ │ │ │ │
┌────┴────┐ ┌────┴────┐ ┌───┴───┐ ┌────┴────┐ ┌───┴───┐
│ <title> │ │ <meta> │ │ <nav> │ │ <main> │ │<footer>│
└────┬────┘ └─────────┘ └───┬───┘ └────┬────┘ └───────┘
│ │ │
"My Page" ┌────┴────┐ ┌──┴──┐
(text) │ <ul> │ │<div>│ ← Cousins
└────┬────┘ └──┬──┘
│ │
┌────┼────┐ ...
│ │ │
<li> <li> <li> ← SiblingsJust like navigating a family reunion, the DOM lets you:
| Action | Family Analogy | DOM Method |
|---|---|---|
| Find your parent | "Who's your mom?" | element.parentNode |
| Find your kids | "Where are your children?" | element.children |
| Find your sibling | "Who's your brother?" | element.nextElementSibling |
| Search the whole family | "Where's cousin Bob?" | document.querySelector('#bob') |
Key insight: Every element, text, and comment in your HTML becomes a "node" in this tree. JavaScript lets you navigate this tree and modify it: changing content, adding elements, or removing them entirely.
What the DOM is NOT
The DOM is NOT Your HTML Source Code
Here's the key thing: your HTML file and the DOM are different things:
<!-- What you wrote (invalid HTML - missing head/body) -->
<!DOCTYPE html>
<html>
Hello, World!
</html><!-- What the browser creates (fixed!) -->
<!DOCTYPE html>
<html>
<head></head>
<body>
Hello, World!
</body>
</html>The browser fixes your mistakes! It adds missing <head> and <body> tags, closes unclosed tags, and corrects nesting errors. The DOM is the corrected version. According to the HTML specification's parsing algorithm, browsers must follow specific error-recovery rules to handle malformed markup consistently across implementations.
The DOM is NOT What You See in DevTools (Exactly)
DevTools shows you something close to the DOM, but it also shows CSS pseudo-elements (::before, ::after) which are NOT part of the DOM:
/* This creates visual content, but NOT DOM nodes */
.quote::before {
content: '"';
}Pseudo-elements exist in the render tree (for display), but not in the DOM (for JavaScript). You can't select them with querySelector!
The DOM is NOT the Render Tree
The Render Tree is what actually gets painted to the screen. It excludes:
<!-- These are in the DOM but NOT in the Render Tree -->
<head>...</head> <!-- Never rendered -->
<script>...</script> <!-- Never rendered -->
<div style="display: none">Hidden</div> <!-- Excluded from render -->DOM Render Tree
┌─────────────────────┐ ┌─────────────────────┐
│ <html> │ │ <html> │
│ <head> │ │ <body> │
│ <title> │ │ <h1> │
│ <body> │ │ "Hello" │
│ <h1>Hello</h1> │ │ <p> │
│ <p>World</p> │ │ "World" │
│ <div hidden> │ │ │
│ Secret! │ │ (no hidden div!) │
│ </div> │ │ │
└─────────────────────┘ └─────────────────────┘The document Object: Your Entry Point
The document object is your gateway to the DOM. It's automatically available in any browser JavaScript. Key properties include document.documentElement (the root <html> element), document.head, document.body, and document.title:
// document is the root of everything
console.log(document) // The entire document
console.log(document.documentElement) // <html> element
console.log(document.head) // <head> element
console.log(document.body) // <body> element
console.log(document.title) // Page title (getter/setter!)
// You can modify the document
document.title = 'New Title' // Changes browser tab titleDOM Node Types Explained
Everything in the DOM is a Node. But not all nodes are created equal!
The Node Type Hierarchy
Node (base class)
│
┌─────────────────────┼─────────────────────┐
│ │ │
Document Element CharacterData
│ │ │
HTMLDocument ┌────┴────┐ ┌─────┴─────┐
│ │ │ │
HTMLElement SVGElement Text Comment
│
┌────────────────┼────────────────┐
│ │ │
HTMLDivElement HTMLSpanElement HTMLInputElement
...Node Types You'll Encounter
| Node Type | nodeType | nodeName | Example |
|---|---|---|---|
| Element | 1 | Tag name (uppercase) | <div>, <p>, <span> |
| Text | 3 | #text | Text inside elements |
| Comment | 8 | #comment | <!-- comment --> |
| Document | 9 | #document | The document object |
| DocumentFragment | 11 | #document-fragment | Virtual container |
const div = document.createElement('div')
console.log(div.nodeType) // 1 (Element)
console.log(div.nodeName) // "DIV"
const text = document.createTextNode('Hello')
console.log(text.nodeType) // 3 (Text)
console.log(text.nodeName) // "#text"
console.log(document.nodeType) // 9 (Document)
console.log(document.nodeName) // "#document"The createElement() and createTextNode() methods create new nodes that you can add to the DOM.
Node Type Constants
Instead of remembering numbers, use the constants:
Node.ELEMENT_NODE // 1
Node.TEXT_NODE // 3
Node.COMMENT_NODE // 8
Node.DOCUMENT_NODE // 9
Node.DOCUMENT_FRAGMENT_NODE // 11
// Check if something is an element
if (node.nodeType === Node.ELEMENT_NODE) {
console.log('This is an element!')
}Visualizing a Real DOM Tree
Given this HTML:
<div id="container">
<h1>Title</h1>
<!-- A comment -->
<p>Paragraph</p>
</div>The actual DOM tree looks like this (including text nodes from whitespace!):
div#container
├── #text (newline + spaces)
├── h1
│ └── #text "Title"
├── #text (newline + spaces)
├── #comment " A comment "
├── #text (newline + spaces)
├── p
│ └── #text "Paragraph"
└── #text (newline)The Whitespace Gotcha! Line breaks and spaces between HTML tags create text nodes. This surprises many developers! We'll see how to handle this in the traversal section.
How to Select DOM Elements
Before you can manipulate an element, you need to find it. JavaScript provides several methods through the document object:
The getElementById() Classic
The getElementById() method is the fastest way to select a single element by its unique ID:
// HTML: <div id="hero">Welcome!</div>
const hero = document.getElementById('hero')
console.log(hero) // <div id="hero">Welcome!</div>
console.log(hero.id) // "hero"
console.log(hero.textContent) // "Welcome!"
// Returns null if not found (not an error!)
const ghost = document.getElementById('nonexistent')
console.log(ghost) // nullIDs must be unique in a document. If you have duplicate IDs, getElementById returns the first one. But don't do this. It's invalid HTML!
getElementsByClassName() and getElementsByTagName()
getElementsByClassName() and getElementsByTagName() select multiple elements by class or tag name:
// HTML:
// <p class="intro">First</p>
// <p class="intro">Second</p>
// <p>Third</p>
const intros = document.getElementsByClassName('intro')
console.log(intros.length) // 2
console.log(intros[0]) // <p class="intro">First</p>
console.log(intros[0].textContent) // "First"
const allParagraphs = document.getElementsByTagName('p')
console.log(allParagraphs.length) // 3The Modern Way: querySelector() and querySelectorAll()
querySelector() and querySelectorAll() use CSS selectors to find elements. Much more powerful!
// querySelector returns the FIRST match (or null)
const firstButton = document.querySelector('button') // First <button> element
const submitBtn = document.querySelector('#submit') // Element with id="submit"
const firstCard = document.querySelector('.card') // First element with class="card"
const navLink = document.querySelector('nav a.active') // <a class="active"> inside <nav>
const dataItem = document.querySelector('[data-id="123"]') // Element with data-id="123"
// querySelectorAll returns ALL matches (NodeList)
const allButtons = document.querySelectorAll('button') // All <button> elements
const allCards = document.querySelectorAll('.card') // All elements with class="card"
const evenRows = document.querySelectorAll('tr:nth-child(even)') // Every even table rowSelector Examples
// By ID
document.querySelector('#main')
// By class
document.querySelector('.active')
document.querySelectorAll('.btn.primary')
// By tag
document.querySelector('header')
document.querySelectorAll('li')
// By attribute
document.querySelector('[type="submit"]')
document.querySelector('[data-modal="login"]')
// Descendant selectors
document.querySelector('nav ul li a')
document.querySelector('.sidebar .widget:first-child')
// Pseudo-selectors (limited support)
document.querySelectorAll('input:not([type="hidden"])')
document.querySelector('p:first-of-type')Live vs Static Collections
This difference trips up many developers. getElementsByClassName() returns a live HTMLCollection, while querySelectorAll() returns a static NodeList:
const liveList = document.getElementsByClassName('item') // LIVE HTMLCollection
const staticList = document.querySelectorAll('.item') // STATIC NodeList
// Start with 3 items
console.log(liveList.length) // 3
console.log(staticList.length) // 3
// Add a new item to the DOM
const newItem = document.createElement('div')
newItem.className = 'item'
document.body.appendChild(newItem)
// Check lengths again
console.log(liveList.length) // 4 (automatically updated!)
console.log(staticList.length) // 3 (still the old snapshot)| Method | Returns | Live? |
|---|---|---|
getElementById() | Element or null | N/A |
getElementsByClassName() | HTMLCollection | Yes (live) |
getElementsByTagName() | HTMLCollection | Yes (live) |
querySelector() | Element or null | N/A |
querySelectorAll() | NodeList | No (static) |
Scoped Selection
You can call selection methods on any element, not just document:
const nav = document.querySelector('nav')
// Find links ONLY inside nav
const navLinks = nav.querySelectorAll('a')
// Find the active link inside nav
const activeLink = nav.querySelector('.active')This is faster than searching the entire document and helps avoid selecting unintended elements.
Performance Comparison
How to Traverse the DOM
Once you have an element, you can navigate to related elements without querying the entire document.
Traversing Downwards (To Children)
const ul = document.querySelector('ul')
// Get ALL child nodes (including text nodes!)
const allChildNodes = ul.childNodes // NodeList
// Get only ELEMENT children (usually what you want)
const elementChildren = ul.children // HTMLCollection
// Get specific children
const firstChild = ul.firstChild // First node (might be text!)
const firstElement = ul.firstElementChild // First ELEMENT child
const lastChild = ul.lastChild // Last node
const lastElement = ul.lastElementChild // Last ELEMENT childThe Text Node Trap! Look at this HTML:
<ul>
<li>One</li>
<li>Two</li>
</ul>What is ul.firstChild? It's NOT the first <li>! It's a text node containing the newline and spaces after <ul>. Use firstElementChild to get the actual <li> element.
Traversing Upwards (To Parents)
const li = document.querySelector('li')
// Direct parent
const parent = li.parentNode // Usually same as parentElement
const parentEl = li.parentElement // Guaranteed to be an Element (or null)
// Find ancestor matching selector (very useful!)
const form = li.closest('form') // Finds nearest ancestor <form>
const card = li.closest('.card') // Finds nearest ancestor with class "card"
// closest() includes the element itself
const self = li.closest('li') // Returns li itself if it matches!The closest() method is useful for event delegation (see Event Loop for how events are processed):
// Handle clicks on any button inside a card
document.addEventListener('click', (e) => {
const card = e.target.closest('.card')
if (card) {
console.log('Clicked inside card:', card)
}
})Traversing Sideways (To Siblings)
const secondLi = document.querySelectorAll('li')[1]
// Previous/next nodes (might be text!)
const prevNode = secondLi.previousSibling
const nextNode = secondLi.nextSibling
// Previous/next ELEMENTS (usually what you want)
const prevElement = secondLi.previousElementSibling
const nextElement = secondLi.nextElementSibling
// Returns null at the boundaries
const firstLi = document.querySelector('li')
console.log(firstLi.previousElementSibling) // null (no previous sibling)Node vs Element Properties Cheat Sheet
| Get... | Node Property (includes text) | Element Property (elements only) |
|---|---|---|
| Parent | parentNode | parentElement |
| Children | childNodes | children |
| First child | firstChild | firstElementChild |
| Last child | lastChild | lastElementChild |
| Previous sibling | previousSibling | previousElementSibling |
| Next sibling | nextSibling | nextElementSibling |
Rule of thumb: Unless you specifically need text nodes, always use the Element variants (children, firstElementChild, nextElementSibling, etc.)
Practical Example: Building a Breadcrumb Trail
// Get all ancestors of an element
function getAncestors(element) {
const ancestors = []
let current = element.parentElement
while (current && current !== document.body) {
ancestors.push(current)
current = current.parentElement
}
return ancestors
}
const deepElement = document.querySelector('.deeply-nested')
console.log(getAncestors(deepElement))
// [<div.parent>, <section>, <main>, ...]Creating and Manipulating Elements
The real power of the DOM is the ability to create, modify, and remove elements dynamically.
Creating Elements
Use createElement() to create new elements and createTextNode() to create text nodes:
// Create a new element
const div = document.createElement('div')
const span = document.createElement('span')
const img = document.createElement('img')
// Create a text node
const text = document.createTextNode('Hello, world!')
// Create a comment node
const comment = document.createComment('This is a comment')
// Elements are created "detached" - not yet in the DOM!
console.log(div.parentNode) // nullAdding Elements to the DOM
There are many ways to add elements. Here's a comprehensive overview using methods like appendChild(), insertBefore(), append(), and prepend():
Adds a node as the last child of a parent:
const ul = document.querySelector('ul')
const li = document.createElement('li')
li.textContent = 'New item'
ul.appendChild(li)
// <ul>
// <li>Existing</li>
// <li>New item</li> ← Added at the end
// </ul>Inserts a node before a reference node:
const ul = document.querySelector('ul')
const existingLi = ul.querySelector('li')
const newLi = document.createElement('li')
newLi.textContent = 'First!'
ul.insertBefore(newLi, existingLi)
// <ul>
// <li>First!</li> ← Inserted before
// <li>Existing</li>
// </ul>Modern methods that accept multiple nodes AND strings:
const div = document.querySelector('div')
// append() - adds to the END
div.append('Text', document.createElement('span'), 'More text')
// prepend() - adds to the START
div.prepend(document.createElement('strong'))Insert as siblings (not children):
const h1 = document.querySelector('h1')
// Insert BEFORE h1 (as previous sibling)
h1.before(document.createElement('nav'))
// Insert AFTER h1 (as next sibling)
h1.after(document.createElement('p'))insertAdjacentHTML() - The Swiss Army Knife
For inserting HTML strings, insertAdjacentHTML() is powerful and fast:
const div = document.querySelector('div')
// Four positions to insert:
div.insertAdjacentHTML('beforebegin', '<p>Before div</p>')
div.insertAdjacentHTML('afterbegin', '<p>First child of div</p>')
div.insertAdjacentHTML('beforeend', '<p>Last child of div</p>')
div.insertAdjacentHTML('afterend', '<p>After div</p>')Visual representation:
<!-- beforebegin -->
<div>
<!-- afterbegin -->
existing content
<!-- beforeend -->
</div>
<!-- afterend -->Removing Elements
Modern and simple. Element removes itself:
const element = document.querySelector('.to-remove')
element.remove() // Gone!Classic method. Remove via parent:
const parent = document.querySelector('ul')
const child = parent.querySelector('li')
parent.removeChild(child)
// Or remove from any element
element.parentNode.removeChild(element)Cloning Elements
Use cloneNode() to duplicate elements:
const original = document.querySelector('.card')
// Shallow clone (element only, no children)
const shallow = original.cloneNode(false)
// Deep clone (element AND all descendants)
const deep = original.cloneNode(true)
// Clones are detached - must add to DOM
document.body.appendChild(deep)ID Collision! If you clone an element with an ID, you'll have duplicate IDs in your document (invalid HTML). Remove or change the ID after cloning:
const clone = original.cloneNode(true)
clone.id = '' // Remove ID
// or
clone.id = 'new-unique-id'DocumentFragment - Batch Operations
When adding many elements, using a DocumentFragment is more efficient:
// Bad: Multiple DOM updates (potentially multiple reflows)
const ul = document.querySelector('ul')
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li')
li.textContent = `Item ${i}`
ul.appendChild(li) // Modifies live DOM each iteration
}// Good: Single DOM update
const ul = document.querySelector('ul')
const fragment = document.createDocumentFragment()
for (let i = 0; i < 1000; i++) {
const li = document.createElement('li')
li.textContent = `Item ${i}`
fragment.appendChild(li) // No DOM update (fragment is detached)
}
ul.appendChild(fragment) // Single DOM update!A DocumentFragment is a lightweight container that:
- Is not part of the DOM tree
- Has no parent
- When appended, only its children are inserted (the fragment itself disappears)
Modern browser optimization: Browsers may batch consecutive DOM modifications and perform a single reflow. However, using DocumentFragment is still the recommended pattern because it's explicit, works consistently across all browsers, and avoids any risk of forced synchronous layouts if you read layout properties between writes.
Modifying Content
Three properties let you read and write element content: innerHTML, textContent, and innerText.
innerHTML - Parse and Insert HTML
const div = document.querySelector('div')
// Read HTML content
console.log(div.innerHTML) // "<p>Hello</p><span>World</span>"
// Write HTML content (parses the string!)
div.innerHTML = '<h1>New Title</h1><p>New paragraph</p>'
// Clear all content
div.innerHTML = ''Security Alert: XSS Vulnerability!
Never use innerHTML with user-provided content:
// DANGEROUS! User could inject: <img src=x onerror="stealCookies()">
div.innerHTML = userInput // NO!
// Safe alternatives:
div.textContent = userInput // Escapes HTML
// or sanitize the input firsttextContent - Plain Text Only
const div = document.querySelector('div')
// Read text (ignores HTML tags)
// <div><p>Hello</p><span>World</span></div>
console.log(div.textContent) // "HelloWorld"
// Write text (HTML is escaped, not parsed)
div.textContent = '<script>alert("XSS")</script>'
// Displays literally: <script>alert("XSS")</script>
// Safe from XSS!innerText - Rendered Text
const div = document.querySelector('div')
// innerText respects CSS visibility
// <div>Hello <span style="display:none">Hidden</span> World</div>
console.log(div.textContent) // "Hello Hidden World"
console.log(div.innerText) // "Hello World" (Hidden is excluded!)When to Use Each
| Property | Use Case |
|---|---|
innerHTML | Inserting trusted HTML (never user input!) |
textContent | Setting/getting plain text (safe, fast) |
innerText | Getting text as user sees it (slower, respects CSS) |
// Performance: textContent is faster than innerText
// because innerText must calculate styles
// Setting text content (both work, textContent is faster)
element.textContent = 'Hello' // Preferred
element.innerText = 'Hello' // Works but slowerHow to Work with DOM Attributes
HTML elements have attributes. JavaScript lets you read, write, and remove them using getAttribute(), setAttribute(), hasAttribute(), and removeAttribute().
Standard Attribute Methods
const link = document.querySelector('a')
// Get attribute value
const href = link.getAttribute('href')
const target = link.getAttribute('target')
// Set attribute value
link.setAttribute('href', 'https://example.com')
link.setAttribute('target', '_blank')
// Check if attribute exists
if (link.hasAttribute('target')) {
console.log('Link opens in new tab')
}
// Remove attribute
link.removeAttribute('target')Properties vs Attributes: The Difference
This confuses many developers! Attributes are in the HTML. Properties are on the DOM object.
<input type="text" value="initial">const input = document.querySelector('input')
// ATTRIBUTE: The original HTML value
console.log(input.getAttribute('value')) // "initial"
// PROPERTY: The current state
console.log(input.value) // "initial"
// User types "new text"...
console.log(input.getAttribute('value')) // Still "initial"!
console.log(input.value) // "new text"
// Reset to attribute value
input.value = input.getAttribute('value')Key differences:
| Aspect | Attribute | Property |
|---|---|---|
| Source | HTML markup | DOM object |
| Access | get/setAttribute() | Direct property access |
| Updates | Manual only | Automatically with user interaction |
| Type | Always string | Can be any type |
// Attribute is always a string
checkbox.getAttribute('checked') // "" or null
// Property is a boolean
checkbox.checked // true or false
// Attribute (string)
input.getAttribute('maxlength') // "10"
// Property (number)
input.maxLength // 10Data Attributes and the dataset API
Custom data attributes start with data- and are accessible via the dataset property:
<div id="user"
data-user-id="123"
data-role="admin"
data-is-active="true">
John Doe
</div>const user = document.querySelector('#user')
// Read data attributes (camelCase!)
console.log(user.dataset.userId) // "123"
console.log(user.dataset.role) // "admin"
console.log(user.dataset.isActive) // "true" (string, not boolean!)
// Write data attributes
user.dataset.lastLogin = '2024-01-15'
// Creates: data-last-login="2024-01-15"
// Delete data attributes
delete user.dataset.role
// Check if exists
if ('userId' in user.dataset) {
console.log('Has user ID')
}Naming Convention: HTML uses kebab-case (data-user-id), JavaScript uses camelCase (dataset.userId). The conversion is automatic!
Common Attribute Shortcuts
Many attributes have direct property shortcuts:
// These pairs are equivalent:
element.id // element.getAttribute('id')
element.className // element.getAttribute('class')
element.href // element.getAttribute('href')
element.src // element.getAttribute('src')
element.title // element.getAttribute('title')
// For class manipulation, use classList (covered next)How to Style DOM Elements with JavaScript
JavaScript can modify element styles in several ways using the style property and classList API.
The style Property (Inline Styles)
const box = document.querySelector('.box')
// Set individual styles (camelCase!)
box.style.backgroundColor = 'blue'
box.style.fontSize = '20px'
box.style.marginTop = '10px'
// Read styles (only reads INLINE styles!)
console.log(box.style.backgroundColor) // "blue"
console.log(box.style.color) // "" (not inline, from stylesheet)
// Set multiple styles at once
box.style.cssText = 'background: red; font-size: 16px; padding: 10px;'
// Remove an inline style
box.style.backgroundColor = '' // Removes the styleelement.style only reads/writes inline styles! To get computed styles (from stylesheets), use getComputedStyle().
getComputedStyle() - Read Actual Styles
Use getComputedStyle() to read the final computed styles:
const box = document.querySelector('.box')
// Get all computed styles
const styles = getComputedStyle(box)
console.log(styles.backgroundColor) // "rgb(0, 0, 255)"
console.log(styles.fontSize) // "16px"
console.log(styles.display) // "block"
// Get pseudo-element styles
const beforeStyles = getComputedStyle(box, '::before')
console.log(beforeStyles.content) // '"Hello"'classList - Manipulate CSS Classes
The classList API is the modern way to add/remove/toggle classes:
const button = document.querySelector('button')
// Add classes
button.classList.add('active')
button.classList.add('btn', 'btn-primary') // Multiple at once
// Remove classes
button.classList.remove('active')
button.classList.remove('btn', 'btn-primary') // Multiple at once
// Toggle (add if missing, remove if present)
button.classList.toggle('active')
// Toggle with condition
button.classList.toggle('active', isActive) // Add if isActive is true
// Check if class exists
if (button.classList.contains('active')) {
console.log('Button is active')
}
// Replace a class
button.classList.replace('btn-primary', 'btn-secondary')
// Iterate over classes
button.classList.forEach(cls => console.log(cls))
// Get number of classes
console.log(button.classList.length) // 2className vs classList
// className is a string (old way)
element.className = 'btn btn-primary' // Replaces ALL classes
element.className += ' active' // Appending is clunky
// classList is a DOMTokenList (modern way)
element.classList.add('active') // Adds without affecting others
element.classList.remove('btn-primary') // Removes specificallyHow Browsers Render the DOM to Pixels
Understanding how browsers render pages helps you write performant code. This is where JavaScript Engines and the browser's rendering engine work together.
From HTML to Pixels
When you load a webpage, the browser goes through these steps:
1. Parse HTML → Build DOM
Browser reads HTML bytes and constructs the Document Object Model tree.
2. Parse CSS → Build CSSOM
CSS is parsed into the CSS Object Model with styling rules.
3. Combine → Render Tree
DOM + CSSOM merge into the Render Tree (only visible elements).
4. Layout (Reflow)
Calculate exact position and size of every element.
5. Paint
Fill in pixels: colors, borders, shadows, text.
6. Composite
Combine layers into the final image using the GPU.
┌─────────────────────────────────────────────────────────────────────────────┐
│ THE CRITICAL RENDERING PATH │
│ │
│ 1. PARSE HTML 2. PARSE CSS 3. BUILD RENDER TREE │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ HTML bytes │ │ CSS bytes │ │ DOM + CSSOM │ │
│ │ ↓ │ │ ↓ │ │ ↘ ↙ │ │
│ │ Characters │ │ Characters │ │ RENDER TREE │ │
│ │ ↓ │ │ ↓ │ │ (visible elements │ │
│ │ Tokens │ │ Tokens │ │ + their styles) │ │
│ │ ↓ │ │ ↓ │ └──────────────────────┘ │
│ │ Nodes │ │ Rules │ │ │
│ │ ↓ │ │ ↓ │ ▼ │
│ │ DOM │ │ CSSOM │ 4. LAYOUT (Reflow) │
│ └──────────────┘ └──────────────┘ ┌──────────────────────┐ │
│ │ Calculate exact │ │
│ │ position & size of │ │
│ │ every element │ │
│ └──────────┬───────────┘ │
│ │ │
│ ▼ │
│ 5. PAINT │
│ ┌──────────────────────┐ │
│ │ Fill in pixels: │ │
│ │ colors, borders, │ │
│ │ shadows, text │ │
│ └──────────┬───────────┘ │
│ │ │
│ ▼ │
│ 6. COMPOSITE │
│ ┌──────────────────────┐ │
│ │ Combine layers into │ │
│ │ final image (GPU) │ │
│ └──────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────┐ │
│ │ PIXELS! │ │
│ └──────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘What's NOT in the Render Tree
The Render Tree only contains visible elements:
<!-- NOT in Render Tree -->
<head>...</head> <!-- head is never rendered -->
<script>...</script> <!-- script tags aren't visible -->
<link rel="stylesheet"> <!-- link tags aren't visible -->
<meta> <!-- meta tags aren't visible -->
<div style="display: none">Hi</div> <!-- display:none excluded -->
<!-- IN the Render Tree (even if not seen) -->
<div style="visibility: hidden">Hi</div> <!-- Takes up space -->
<div style="opacity: 0">Hi</div> <!-- Takes up space -->Layout (Reflow) - The Expensive Step
Layout calculates the geometry of every element: position, size, margins, etc.
Reflow is triggered when:
- Adding/removing elements
- Changing element dimensions (width, height, padding, margin)
- Changing font size
- Resizing the window
- Reading certain properties (more on this below!)
Paint - Drawing Pixels
After layout, the browser paints the pixels: text, colors, images, borders, shadows.
Repaint (without reflow) happens when:
- Changing colors
- Changing background-image
- Changing visibility
- Changing box-shadow (sometimes)
Composite - Layering
Modern browsers separate content into layers and use the GPU to composite them. This is why some animations are smooth:
/* These properties can animate without reflow/repaint */
transform: translateX(100px); /* GPU accelerated! */
opacity: 0.5; /* GPU accelerated! */
/* These properties cause reflow */
left: 100px; /* Avoid for animations! */
width: 200px; /* Avoid for animations! */How to Optimize DOM Performance
DOM operations can be slow. Here's how to keep your pages fast.
Cache DOM References
// Bad: Queries the DOM every iteration
for (let i = 0; i < 1000; i++) {
document.querySelector('.result').textContent += i
}
// Good: Query once, reuse
const result = document.querySelector('.result')
for (let i = 0; i < 1000; i++) {
result.textContent += i
}// Even better: Build string, set once
const result = document.querySelector('.result')
let text = ''
for (let i = 0; i < 1000; i++) {
text += i
}
result.textContent = textBatch DOM Updates
// Avoid: Multiple style changes (may trigger multiple reflows)
element.style.width = '100px'
element.style.height = '200px'
element.style.margin = '10px'
// Better: Single style assignment with cssText
element.style.cssText = 'width: 100px; height: 200px; margin: 10px;'
// Best: Use a CSS class (cleanest and most maintainable)
element.classList.add('my-styles')
// Good: DocumentFragment for multiple elements
const fragment = document.createDocumentFragment()
items.forEach(item => {
const li = document.createElement('li')
li.textContent = item
fragment.appendChild(li)
})
ul.appendChild(fragment) // Single DOM updateWhy batch? While modern browsers often optimize consecutive style changes into a single reflow, this optimization breaks if you read a layout property (like offsetWidth) between writes. Batching explicitly avoids this risk and makes your intent clear.
Avoid Layout Thrashing
Layout thrashing occurs when you alternate between reading and writing DOM properties:
// TERRIBLE: Forces layout on EVERY iteration
boxes.forEach(box => {
const width = box.offsetWidth // Read (forces layout)
box.style.width = (width + 10) + 'px' // Write (invalidates layout)
})
// GOOD: Batch reads, then batch writes
const widths = boxes.map(box => box.offsetWidth) // Read all
boxes.forEach((box, i) => {
box.style.width = (widths[i] + 10) + 'px' // Write all
})Properties that trigger layout when read:
| Property | What It Returns |
|---|---|
offsetWidth / offsetHeight | Element's layout width/height including borders |
offsetTop / offsetLeft | Position relative to offset parent |
clientWidth / clientHeight | Inner dimensions (padding but no border) |
scrollWidth / scrollHeight | Full scrollable dimensions |
scrollTop / scrollLeft | Current scroll position |
getBoundingClientRect() | Position and size relative to viewport |
getComputedStyle() | All computed CSS values |
// Any of these reads forces a layout calculation
const width = element.offsetWidth // Layout triggered!
const rect = element.getBoundingClientRect() // Layout triggered!
const styles = getComputedStyle(element) // Layout triggered!Use requestAnimationFrame for Visual Changes
Use requestAnimationFrame() to batch visual changes with the browser's render cycle:
// Bad: DOM changes at unpredictable times
window.addEventListener('scroll', () => {
element.style.transform = `translateY(${window.scrollY}px)`
})
// Good: Batch visual changes with next frame
let ticking = false
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
element.style.transform = `translateY(${window.scrollY}px)`
ticking = false
})
ticking = true
}
})The #1 DOM Mistake: Using innerHTML with User Input
The most dangerous DOM mistake is using innerHTML with untrusted content. This opens your application to Cross-Site Scripting (XSS) attacks.
┌─────────────────────────────────────────────────────────────────────────┐
│ innerHTML: THE SECURITY TRAP │
├─────────────────────────────────────────────────────────────────────────┤
│ │
│ ❌ DANGEROUS ✓ SAFE │
│ ───────────── ────── │
│ │
│ User Input: User Input: │
│ "<img src=x onerror=alert('XSS')>" "<img src=x onerror=...>" │
│ │ │ │
│ ▼ ▼ │
│ element.innerHTML = userInput element.textContent = input │
│ │ │ │
│ ▼ ▼ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ BROWSER PARSES │ │ DISPLAYED AS │ │
│ │ AS REAL HTML! │ │ PLAIN TEXT │ │
│ │ │ │ │ │
│ │ 🚨 Script runs! │ │ "<img src=..." │ │
│ │ Cookies stolen! │ │ (harmless) │ │
│ └─────────────────┘ └─────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────────────┘// ❌ DANGEROUS - Never do this with user input!
const username = getUserInput() // User enters: <img src=x onerror="stealCookies()">
div.innerHTML = `Welcome, ${username}!`
// The malicious script EXECUTES!
// ✓ SAFE - textContent escapes HTML
const username = getUserInput()
div.textContent = `Welcome, ${username}!`
// Displays: Welcome, <img src=x onerror="stealCookies()">!
// The HTML is shown as text, not executed
// ✓ SAFE - Create elements programmatically
const username = getUserInput()
const welcomeText = document.createTextNode(`Welcome, ${username}!`)
div.appendChild(welcomeText)The Trap: innerHTML looks convenient, but it parses strings as real HTML. If that string contains user input, attackers can inject <script> tags, malicious event handlers, or other dangerous code. Always use textContent for user-provided content.
Other Common Mistakes
Event Propagation: Bubbling and Capturing
When an event occurs on a DOM element, it doesn't just trigger on that element. It travels through the DOM tree in a process called event propagation. Understanding this helps with event handling.
The Three Phases
Every DOM event goes through three phases:
1. CAPTURING PHASE ↓ (from window → target's parent)
2. TARGET PHASE ● (at the target element)
3. BUBBLING PHASE ↑ (from target's parent → window)// Most events bubble UP by default
document.querySelector('.child').addEventListener('click', (e) => {
console.log('Child clicked')
})
document.querySelector('.parent').addEventListener('click', (e) => {
console.log('Parent also receives the click!') // This fires too!
})Capturing vs Bubbling
By default, event listeners fire during the bubbling phase (bottom-up). You can listen during the capturing phase (top-down) with the third parameter:
// Bubbling (default) — fires on the way UP
element.addEventListener('click', handler)
element.addEventListener('click', handler, false)
// Capturing — fires on the way DOWN
element.addEventListener('click', handler, true)
element.addEventListener('click', handler, { capture: true })// Practical example: see the order
document.querySelector('.parent').addEventListener('click', () => {
console.log('1. Parent - capturing')
}, true)
document.querySelector('.child').addEventListener('click', () => {
console.log('2. Child - target')
})
document.querySelector('.parent').addEventListener('click', () => {
console.log('3. Parent - bubbling')
})
// Click on child outputs: 1, 2, 3Stopping Propagation
You can stop an event from traveling further:
element.addEventListener('click', (e) => {
e.stopPropagation() // Stop bubbling/capturing
// Parent handlers won't fire
})
element.addEventListener('click', (e) => {
e.stopImmediatePropagation() // Stop ALL handlers, even on same element
})Use stopPropagation() sparingly! It breaks event delegation and can make debugging difficult. Usually there's a better solution.
Preventing Default Behavior
Don't confuse propagation with default behavior:
// Prevent the browser's default action (e.g., following a link)
link.addEventListener('click', (e) => {
e.preventDefault() // Don't navigate
// Event still bubbles unless you also call stopPropagation()
})
// Common use cases:
// - Prevent form submission: form.addEventListener('submit', e => e.preventDefault())
// - Prevent link navigation: link.addEventListener('click', e => e.preventDefault())
// - Prevent context menu: element.addEventListener('contextmenu', e => e.preventDefault())The event.target vs event.currentTarget
This distinction matters for event delegation:
document.querySelector('.parent').addEventListener('click', (e) => {
console.log(e.target) // The element that was actually clicked
console.log(e.currentTarget) // The element with the listener (.parent)
console.log(this) // Same as currentTarget (in regular functions)
})// If you click on a <span> inside .parent:
// e.target = <span> (what you clicked)
// e.currentTarget = .parent (what has the listener)Events That Don't Bubble
Most events bubble, but some don't:
| Event | Bubbles? | Notes |
|---|---|---|
click, mousedown, keydown | Yes | Most user events bubble |
focus, blur | No | Use focusin/focusout for bubbling versions |
mouseenter, mouseleave | No | Use mouseover/mouseout for bubbling versions |
load, unload, scroll | No | Window/document events |
// focus doesn't bubble, but focusin does
form.addEventListener('focusin', (e) => {
console.log('Something in the form was focused:', e.target)
})Common DOM Patterns
Event Delegation
Instead of adding listeners to many elements, add one to a parent. This pattern relies on event bubbling. When you click a child element, the event bubbles up to the parent where your listener catches it:
// Bad: Many listeners
document.querySelectorAll('.btn').forEach(btn => {
btn.addEventListener('click', handleClick)
})
// Good: One listener with delegation
document.querySelector('.button-container').addEventListener('click', (e) => {
const btn = e.target.closest('.btn')
if (btn) {
handleClick(e)
}
})Benefits:
- Works for dynamically added elements
- Less memory usage
- Easier cleanup (uses closures to maintain handler references)
Checking if Element Exists
// Using querySelector (returns null if not found)
const element = document.querySelector('.maybe-exists')
if (element) {
element.textContent = 'Found!'
}
// Optional chaining (modern)
document.querySelector('.maybe-exists')?.classList.add('active')
// With getElementById
const el = document.getElementById('myId')
if (el !== null) {
// Element exists
}Waiting for DOM Ready
Listen for the DOMContentLoaded event to know when the DOM is ready:
// Modern: DOMContentLoaded (DOM ready, images may still be loading)
document.addEventListener('DOMContentLoaded', () => {
console.log('DOM is ready!')
// Safe to query elements
})
// Full page load (including images, stylesheets)
window.addEventListener('load', () => {
console.log('Everything loaded!')
})
// If script is at end of body, DOM is already ready
// <script src="app.js"></script> <!-- Just before </body> -->
// Modern: defer attribute (script loads in parallel, runs after DOM ready)
// <script src="app.js" defer></script>Best practice: Put your <script> tags just before </body> or use the defer attribute. Then you don't need to wait for DOMContentLoaded.
Common Misconceptions
Classic Interview Questions
Question 1: What's the difference between document.querySelector and document.getElementById?
Question 2: Explain event delegation and why it's useful
Question 3: What causes layout thrashing and how do you avoid it?
Question 4: What's the difference between innerHTML, textContent, and innerText?
Question 5: How do you efficiently add 1000 elements to the DOM?
Question 6: What's the difference between attributes and properties?
Key Takeaways
The key things to remember:
-
The DOM is a tree — Elements are nodes with parent, child, and sibling relationships
-
DOM ≠ HTML source — The browser fixes errors and JavaScript modifies it
-
Use querySelector — More flexible than getElementById, accepts any CSS selector
-
Element vs Node properties — Use
children,firstElementChild, etc. to skip text nodes -
closest() is your friend — Perfect for event delegation and finding ancestor elements
-
innerHTML is dangerous — Never use with user input; use textContent instead
-
Attributes vs Properties — Attributes are HTML source, properties are live DOM state
-
classList over className — Use add/remove/toggle for cleaner class manipulation
-
Batch DOM operations — Use DocumentFragment or build strings to minimize reflows
-
Avoid layout thrashing — Don't alternate reading and writing layout properties
Test Your Knowledge
Frequently Asked Questions
Related Concepts
Event Loop
How JavaScript handles async operations and DOM events
JavaScript Engines
How V8 and other engines parse and execute your DOM code
Scope and Closures
Understanding variable scope in event handlers and callbacks
Design Patterns
Patterns like Observer for reactive DOM updates
Content coming soon.Reference
Document Object Model (DOM) — MDN
The comprehensive MDN reference for all DOM interfaces, methods, and properties.
Document Interface — MDN
The Document interface representing the web page loaded in the browser.
Element Interface — MDN
The base class for all element objects in a Document.
Node Interface — MDN
The abstract base class for DOM nodes including elements, text, and comments.
NodeList Interface — MDN
Collections of nodes returned by querySelectorAll and other methods.
HTMLCollection Interface — MDN
Live collections of elements returned by getElementsByClassName and similar.
Articles
Eloquent JavaScript: The Document Object Model
A free book chapter with runnable code examples you can edit right in the browser. Includes exercises at the end to test your understanding.
How To Understand and Modify the DOM in JavaScript
Tania Rascia walks through each concept with side-by-side HTML and JavaScript examples. Great for visual learners who want to see code and results together.
What's the Document Object Model, and why you should know how to use it
Builds a simple project while explaining DOM concepts. Good if you learn better by building something rather than reading theory.
What is the DOM?
Short read that clears up the "DOM vs HTML source" confusion with visual examples. Explains why DevTools shows something different from View Source.
Traversing the DOM with JavaScript
Zell explains the difference between Node and Element traversal methods with clear diagrams. Includes the "whitespace text node" gotcha that trips up beginners.
DOM Tree
Interactive examples you can edit and run in the browser. Part of a larger DOM tutorial series if you want to keep going deeper.
How to traverse the DOM in JavaScript
Covers every traversal method with console output screenshots. Useful reference when you forget which property to use for siblings vs children.
Render Tree Construction
Google's official explanation of the Critical Rendering Path. Essential reading if you want to understand why some DOM operations are slow.
What, exactly, is the DOM?
Compares DOM vs HTML source vs Render Tree side by side with diagrams. Clears up the confusion about what DevTools actually shows you.
JavaScript DOM Tutorial
A multi-part tutorial organized by topic, so you can jump to exactly what you need. Each page is self-contained with try-it-yourself examples.
Event Propagation — MDN
MDN's guide to event handling including bubbling, capturing, and delegation patterns.
Bubbling and Capturing
Animated diagrams showing events traveling up and down the DOM tree. Makes the three-phase model (capture, target, bubble) easy to visualize.
Videos
JavaScript DOM Manipulation – Full Course for Beginners
A 2-hour freeCodeCamp course that builds multiple projects while teaching DOM concepts. Good if you want structured learning from zero to comfortable.
JavaScript DOM Tutorial
A playlist of short, focused videos (5-10 min each). Pick the topic you need instead of watching everything in order.
JavaScript DOM Crash Course
Brad Traversy's 4-part series (this is part 1). Builds a task list project by the end, so you see DOM skills applied to something real.
JavaScript DOM Manipulation Methods
Web Dev Simplified explains createElement, appendChild, and other manipulation methods.
JavaScript DOM Traversal Methods
Web Dev Simplified covers parent, child, and sibling traversal methods.
Event Propagation - JavaScript Event Bubbling and Propagation
Steve Griffith explains event bubbling, capturing, and how to control event flow.