How to Search the DOM in JavaScript
Walking the DOM from node to node works well when you know exactly where an element sits in the tree. But most of the time, you need to find elements by their ID, class, tag name, or a complex CSS selector without manually traversing parent-child-sibling relationships.
JavaScript provides a powerful set of search methods that let you locate any element (or group of elements) in the document. Some return a single element, others return collections. Some collections are live and update automatically. Others are static snapshots. Knowing which method to use, and understanding the behavior of what it returns, is essential for writing correct and performant DOM code.
This guide covers every DOM search method available, from getElementById to querySelector to the lesser-known matches, closest, and contains. By the end, you will know exactly which method to reach for in every situation.
document.getElementById(id)
The fastest and simplest way to find an element is by its id attribute. Since IDs must be unique in a valid HTML document, this method always returns a single element or null.
<div id="main-header">
<h1>Welcome</h1>
</div>
let header = document.getElementById("main-header");
console.log(header); // <div id="main-header">...</div>
// If no element has that ID, it returns null
let missing = document.getElementById("nonexistent");
console.log(missing); // null
Key Characteristics
getElementById is only available on the document object. You cannot call it on an arbitrary element:
// ✅ Correct
document.getElementById("main-header");
// ❌ Wrong: getElementById is not available on elements
// let div = document.querySelector("div");
// div.getElementById("child"); // TypeError: div.getElementById is not a function
The id parameter is case-sensitive:
// HTML: <div id="MyBox">...</div>
document.getElementById("MyBox"); // ✅ Found
document.getElementById("mybox"); // ❌ null
document.getElementById("MYBOX"); // ❌ null
The Global Variable Shortcut (and Why to Avoid It)
Browsers create a global variable for every element with an id. This means you can technically access an element by just typing its ID as a variable name:
<div id="content">Hello</div>
// This works (but don't do it!)
console.log(content); // <div id="content">Hello</div>
// It is the same as window.content or window["content"]
console.log(window.content); // <div id="content">Hello</div>
Never rely on this behavior. It breaks if:
- You declare a variable with the same name (
let content = "something"shadows the global). - The ID conflicts with an existing
windowproperty (likewindow.name,window.location,window.status). - It makes your code confusing and hard to debug.
Always use document.getElementById() explicitly:
// Bad: relies on implicit global
content.style.color = "red";
// Good: explicit and clear
document.getElementById("content").style.color = "red";
When to Use getElementById
Use it when you need to find a single specific element and you know its ID. It is the fastest DOM search method because browsers maintain an internal hash map of element IDs for instant lookup.
element.querySelector(css)
The querySelector method accepts any valid CSS selector and returns the first matching element, or null if no element matches.
<nav>
<ul class="menu">
<li class="item active">Home</li>
<li class="item">About</li>
<li class="item">Contact</li>
</ul>
</nav>
// Find the first element with class "item"
let firstItem = document.querySelector(".item");
console.log(firstItem.textContent); // "Home"
// Find the first <li> inside .menu
let menuItem = document.querySelector(".menu li");
console.log(menuItem.textContent); // "Home"
// Find the element with class "active"
let active = document.querySelector(".item.active");
console.log(active.textContent); // "Home"
// Use any CSS selector: attribute selectors, pseudo-classes, combinators
let link = document.querySelector('a[href^="https"]');
let firstChild = document.querySelector("ul > li:first-child");
let checkedBox = document.querySelector('input[type="checkbox"]:checked');
Calling querySelector on an Element
Unlike getElementById, querySelector can be called on any element, not just document. When called on an element, it searches only within that element's descendants:
<div id="sidebar">
<p class="intro">Sidebar intro</p>
</div>
<div id="main">
<p class="intro">Main intro</p>
</div>
// Search within #sidebar only
let sidebar = document.getElementById("sidebar");
let sidebarIntro = sidebar.querySelector(".intro");
console.log(sidebarIntro.textContent); // "Sidebar intro"
// Search within #main only
let main = document.getElementById("main");
let mainIntro = main.querySelector(".intro");
console.log(mainIntro.textContent); // "Main intro"
Scoping querySelector to a specific parent element is a good practice. It narrows the search, makes your code more precise, and avoids accidentally selecting elements from other parts of the page.
null When Not Found
If no element matches the selector, querySelector returns null. Always check for this before accessing properties:
let result = document.querySelector(".nonexistent-class");
console.log(result); // null
// ❌ This will throw a TypeError
// result.textContent; // TypeError: Cannot read properties of null
// ✅ Check first
if (result) {
console.log(result.textContent);
} else {
console.log("Element not found");
}
// ✅ Or use optional chaining
console.log(result?.textContent); // undefined (no error)
element.querySelectorAll(css)
The querySelectorAll method works like querySelector but returns all matching elements as a static NodeList, not just the first one.
<ul>
<li class="fruit">Apple</li>
<li class="fruit">Banana</li>
<li class="vegetable">Carrot</li>
<li class="fruit">Date</li>
</ul>
let fruits = document.querySelectorAll(".fruit");
console.log(fruits); // NodeList(3) [li.fruit, li.fruit, li.fruit]
console.log(fruits.length); // 3
// Access by index
console.log(fruits[0].textContent); // "Apple"
console.log(fruits[2].textContent); // "Date"
// Iterate with forEach
fruits.forEach(fruit => {
console.log(fruit.textContent);
});
// "Apple"
// "Banana"
// "Date"
// Iterate with for...of
for (let fruit of fruits) {
console.log(fruit.textContent);
}
The Result Is a Static NodeList
This is a critical point. The NodeList returned by querySelectorAll is a snapshot of the DOM at the moment you called the method. If you add or remove elements afterward, the NodeList does not update:
let items = document.querySelectorAll("li");
console.log(items.length); // 4
// Add a new <li>
let newLi = document.createElement("li");
newLi.textContent = "Elderberry";
newLi.className = "fruit";
document.querySelector("ul").appendChild(newLi);
// The NodeList does NOT update
console.log(items.length); // Still 4
// You need to query again
let updatedItems = document.querySelectorAll("li");
console.log(updatedItems.length); // 5
Empty NodeList When Nothing Matches
If no elements match, querySelectorAll returns an empty NodeList, not null. This is an important difference from querySelector:
let results = document.querySelectorAll(".nonexistent");
console.log(results); // NodeList []
console.log(results.length); // 0
// Safe to iterate: the loop simply doesn't execute
results.forEach(el => {
console.log(el); // Never runs
});
Converting NodeList to Array
While NodeList supports forEach and for...of, it does not have array methods like map, filter, or reduce. Convert it to an array when you need those:
let items = document.querySelectorAll(".fruit");
// ❌ No .map() on NodeList
// items.map(el => el.textContent); // TypeError
// ✅ Convert to array first
let names = Array.from(items).map(el => el.textContent);
console.log(names); // ["Apple", "Banana", "Date"]
// ✅ Spread syntax also works
let namesAlt = [...items].map(el => el.textContent);
console.log(namesAlt); // ["Apple", "Banana", "Date"]
// ✅ Filter for specific items
let shortNames = Array.from(items)
.filter(el => el.textContent.length <= 5)
.map(el => el.textContent);
console.log(shortNames); // ["Apple", "Date"]
Complex CSS Selectors
querySelectorAll accepts any valid CSS selector, including complex ones:
// Multiple selectors (comma-separated), like CSS grouping
let headings = document.querySelectorAll("h1, h2, h3");
// Attribute selectors
let externalLinks = document.querySelectorAll('a[target="_blank"]');
let dataItems = document.querySelectorAll("[data-role='admin']");
// Pseudo-classes
let oddRows = document.querySelectorAll("tr:nth-child(odd)");
let enabledInputs = document.querySelectorAll("input:not(:disabled)");
let emptyDivs = document.querySelectorAll("div:empty");
// Combinators
let directChildren = document.querySelectorAll("ul > li");
let adjacentSiblings = document.querySelectorAll("h2 + p");
let descendants = document.querySelectorAll(".container .card .title");
Pseudo-elements like ::before and ::after are not real DOM nodes. You cannot select them with querySelector or querySelectorAll. They exist only in the CSS rendering layer.
// ❌ This returns nothing: pseudo-elements are not in the DOM
let before = document.querySelectorAll("p::before");
console.log(before.length); // 0
element.getElementsByClassName(class)
This method returns all elements with the given CSS class name. It returns a live HTMLCollection that updates automatically when the DOM changes.
<div>
<p class="highlight">First</p>
<p class="normal">Second</p>
<p class="highlight">Third</p>
</div>
let highlighted = document.getElementsByClassName("highlight");
console.log(highlighted); // HTMLCollection(2) [p.highlight, p.highlight]
console.log(highlighted.length); // 2
console.log(highlighted[0].textContent); // "First"
Multiple Classes
You can pass multiple class names separated by spaces. The method returns elements that have all of the specified classes:
// HTML: <div class="card featured active">...</div>
let featuredActive = document.getElementsByClassName("featured active");
// Returns elements that have BOTH "featured" AND "active" classes
Live Collection Behavior
This is where getElementsByClassName differs fundamentally from querySelectorAll:
let highlighted = document.getElementsByClassName("highlight");
console.log(highlighted.length); // 2
// Add a new element with the "highlight" class
let newP = document.createElement("p");
newP.className = "highlight";
newP.textContent = "New highlight";
document.querySelector("div").appendChild(newP);
// The collection updates automatically!
console.log(highlighted.length); // 3
// Remove the class from an existing element
highlighted[0].classList.remove("highlight");
// The collection updates again!
console.log(highlighted.length); // 2
Can Be Called on Any Element
Like querySelector, this method can be scoped to a specific element:
let sidebar = document.getElementById("sidebar");
let sidebarButtons = sidebar.getElementsByClassName("btn");
element.getElementsByTagName(tag)
Returns all elements with the given tag name as a live HTMLCollection.
// Get all paragraphs in the document
let paragraphs = document.getElementsByTagName("p");
console.log(paragraphs.length);
// Get all links
let links = document.getElementsByTagName("a");
// Get all images
let images = document.getElementsByTagName("img");
The Wildcard *
Passing "*" returns all elements:
let allElements = document.getElementsByTagName("*");
console.log(allElements.length); // Every element in the document
Scoped to a Parent
let nav = document.querySelector("nav");
let navLinks = nav.getElementsByTagName("a");
// Only <a> elements inside <nav>
Tag Names Are Case-Insensitive in HTML
For HTML documents, the tag name is case-insensitive:
// These all return the same collection in HTML documents
document.getElementsByTagName("div");
document.getElementsByTagName("DIV");
document.getElementsByTagName("Div");
document.getElementsByName(name)
This method searches for elements by their name attribute. It is most commonly used with form elements. It returns a live NodeList and is only available on document.
<form>
<input type="radio" name="color" value="red"> Red
<input type="radio" name="color" value="green"> Green
<input type="radio" name="color" value="blue"> Blue
</form>
let colorInputs = document.getElementsByName("color");
console.log(colorInputs.length); // 3
// Find the selected radio button
for (let input of colorInputs) {
if (input.checked) {
console.log("Selected color:", input.value);
}
}
getElementsByName is rarely used in modern code. querySelectorAll with an attribute selector achieves the same result with more flexibility:
// Same result as getElementsByName("color"), but more flexible
let colorInputs = document.querySelectorAll('input[name="color"]');
element.matches(css): Testing Against a Selector
The matches method does not search the DOM. Instead, it checks whether a specific element matches a given CSS selector. It returns true or false.
<ul>
<li class="item active" data-priority="high">Task 1</li>
<li class="item" data-priority="low">Task 2</li>
<li class="item completed" data-priority="high">Task 3</li>
</ul>
let items = document.querySelectorAll(".item");
for (let item of items) {
if (item.matches(".active")) {
console.log(`Active: ${item.textContent}`);
}
if (item.matches("[data-priority='high']")) {
console.log(`High priority: ${item.textContent}`);
}
if (item.matches(".item.completed")) {
console.log(`Completed: ${item.textContent}`);
}
}
// Output:
// "Active: Task 1"
// "High priority: Task 1"
// "High priority: Task 3"
// "Completed: Task 3"
Practical Use: Filtering Elements
matches is especially useful when you already have a collection of elements and want to filter them:
let allElements = document.querySelectorAll("*");
let interactiveElements = Array.from(allElements).filter(el =>
el.matches("a, button, input, select, textarea")
);
console.log(`Found ${interactiveElements.length} interactive elements`);
Use in Event Delegation
One of the most common uses of matches is in event delegation, where a single handler on a parent element determines which child triggered the event:
document.querySelector("ul").addEventListener("click", function(event) {
// Check if the clicked element (or its ancestor) is an .item
if (event.target.matches(".item")) {
console.log("Clicked item:", event.target.textContent);
}
if (event.target.matches(".item.active")) {
console.log("Clicked the active item!");
}
});
element.closest(css): Nearest Matching Ancestor
The closest method walks up the DOM tree (from the element itself toward the root) and returns the first ancestor that matches the given CSS selector. If no ancestor matches, it returns null. The element itself is included in the search.
<div class="container">
<article class="post">
<header>
<h2>Article Title</h2>
<button class="delete-btn">Delete</button>
</header>
<p>Article content...</p>
</article>
</div>
let button = document.querySelector(".delete-btn");
// Find the nearest ancestor with class "post"
let post = button.closest(".post");
console.log(post); // <article class="post">...</article>
// Find the nearest ancestor <div>
let div = button.closest("div");
console.log(div); // <div class="container">...</div>
// The element itself is checked too
let self = button.closest(".delete-btn");
console.log(self === button); // true
// Returns null if no match
let form = button.closest("form");
console.log(form); // null (no <form> ancestor)
closest vs. parentElement Chain
Without closest, you would need to walk up manually:
// WITHOUT closest: manual traversal
function findAncestor(element, selector) {
let current = element;
while (current) {
if (current.matches(selector)) return current;
current = current.parentElement;
}
return null;
}
// WITH closest: one line
let post = button.closest(".post");
Real-World Use Case: Event Delegation
closest is extremely useful in event delegation, especially when the click target might be a child element inside the actual element you care about:
<ul id="task-list">
<li class="task">
<span class="task-name">Buy groceries</span>
<button class="delete-btn">
<span class="icon">✕</span>
</button>
</li>
<li class="task">
<span class="task-name">Clean house</span>
<button class="delete-btn">
<span class="icon">✕</span>
</button>
</li>
</ul>
document.getElementById("task-list").addEventListener("click", function(event) {
// The user might click the <span class="icon"> inside the button
// closest() walks up to find the actual button
let deleteBtn = event.target.closest(".delete-btn");
if (deleteBtn) {
// Find the parent task
let task = deleteBtn.closest(".task");
let taskName = task.querySelector(".task-name").textContent;
console.log(`Deleting task: ${taskName}`);
task.remove();
}
});
Without closest, if the user clicks the <span class="icon"> inside the button, event.target would be the <span>, not the <button>. closest reliably finds the button regardless of which inner element was clicked.
Live vs. Static Collections: Performance and Behavior
Understanding the difference between live and static collections is not just an academic exercise. It affects correctness and performance in real applications.
Quick Reference
| Method | Returns | Live or Static |
|---|---|---|
getElementById | Single element or null | N/A |
querySelector | Single element or null | N/A |
querySelectorAll | NodeList | Static |
getElementsByClassName | HTMLCollection | Live |
getElementsByTagName | HTMLCollection | Live |
getElementsByName | NodeList | Live |
Live Collection Pitfalls
Live collections update automatically, which can cause bugs when you modify the DOM during iteration:
// PROBLEM: Removing elements while iterating a live collection
let items = document.getElementsByClassName("removable");
console.log(items.length); // 3
// ❌ This loop does NOT remove all items
for (let i = 0; i < items.length; i++) {
items[i].remove();
}
// After removing items[0], items[1] shifts to items[0].
// But i is now 1, so it skips the new items[0].
// Result: only some items are removed!
// ✅ FIX 1: Loop backwards
let items = document.getElementsByClassName("removable");
for (let i = items.length - 1; i >= 0; i--) {
items[i].remove();
}
// ✅ FIX 2: Use a while loop (since length decreases as items are removed)
let items = document.getElementsByClassName("removable");
while (items.length > 0) {
items[0].remove();
}
// ✅ FIX 3: Use querySelectorAll (static, no shifting)
let items = document.querySelectorAll(".removable");
items.forEach(item => item.remove());
When Live Collections Are Useful
Live collections are not always a problem. Sometimes, auto-updating behavior is exactly what you want:
// A live reference to all error messages on the page
let errors = document.getElementsByClassName("error");
// A function that runs periodically or after validation
function checkErrors() {
if (errors.length > 0) {
console.log(`There are ${errors.length} errors on the page`);
} else {
console.log("No errors!");
}
}
// As errors are added/removed from the DOM, `errors.length` stays accurate
// without needing to re-query
Performance Considerations
There is a common misconception that getElementsByClassName and getElementsByTagName are significantly faster than querySelectorAll. Here is the nuanced reality:
Initial call speed: getElementsByClassName and getElementsByTagName can be marginally faster for the initial call because they create a live collection lazily (the browser does not need to traverse the entire DOM immediately).
Repeated access: If you access the length or elements of a live collection repeatedly, the browser may need to re-validate the collection each time, which can be slower if the DOM has changed.
For most applications, the performance difference is negligible. Choose based on correctness and clarity, not micro-optimization:
// Modern best practice: use querySelectorAll for predictable, static results
let items = document.querySelectorAll(".card");
// Use getElementById for single lookups by ID (always fast)
let header = document.getElementById("main-header");
// Use getElementsByClassName/TagName only when you need live behavior
let liveErrors = document.getElementsByClassName("error");
Unless you have a specific reason to use a live collection, prefer querySelector and querySelectorAll. They are more flexible (any CSS selector), return predictable static results, and work consistently in all situations.
element.contains(otherElement): Checking Ancestry
The contains method checks whether one element is a descendant of another (or is the element itself). It returns true or false.
<div id="container">
<section>
<p id="target">Hello</p>
</section>
</div>
<p id="outside">Outside</p>
let container = document.getElementById("container");
let target = document.getElementById("target");
let outside = document.getElementById("outside");
// Is target inside container?
console.log(container.contains(target)); // true
// Is outside inside container?
console.log(container.contains(outside)); // false
// An element "contains" itself
console.log(container.contains(container)); // true
// document.body contains everything visible
console.log(document.body.contains(target)); // true
Practical Use Cases
Checking if a click happened inside a specific area (useful for closing dropdowns or modals):
document.addEventListener("click", function(event) {
let dropdown = document.getElementById("dropdown");
// If the click was outside the dropdown, close it
if (!dropdown.contains(event.target)) {
dropdown.classList.remove("open");
console.log("Closed dropdown - clicked outside");
}
});
Validating element relationships before DOM operations:
function moveElement(element, newParent) {
// Prevent circular nesting: don't move an element into its own descendant
if (element.contains(newParent)) {
console.error("Cannot move an element inside its own descendant!");
return;
}
newParent.appendChild(element);
}
Choosing the Right Method
Here is a decision guide for selecting the appropriate search method:
| Situation | Best Method |
|---|---|
| Find one element by ID | getElementById("id") |
| Find one element by any CSS selector | querySelector(".class") |
| Find all elements by CSS selector | querySelectorAll(".class") |
| Need a live-updating collection | getElementsByClassName("class") |
| Need all elements of a tag type (live) | getElementsByTagName("tag") |
| Check if an element matches a selector | element.matches(".selector") |
| Find the nearest matching ancestor | element.closest(".selector") |
| Check if one element contains another | parent.contains(child) |
| Find form elements by name attribute | getElementsByName("name") |
A Practical Comparison
Consider finding all <li> elements with the class "active" inside a <ul id="menu">:
// Method 1: querySelectorAll (recommended, most readable)
let items = document.querySelectorAll("#menu li.active");
// Method 2: getElementById + querySelectorAll (scoped, also good)
let menu = document.getElementById("menu");
let items2 = menu.querySelectorAll("li.active");
// Method 3: getElementsByClassName (live collection)
let menu3 = document.getElementById("menu");
let items3 = menu3.getElementsByClassName("active");
// Note: this returns ALL elements with class "active", not just <li>
// Method 4: getElementsByTagName + manual filter (verbose, avoid)
let menu4 = document.getElementById("menu");
let allLi = menu4.getElementsByTagName("li");
let items4 = Array.from(allLi).filter(li => li.classList.contains("active"));
Method 1 or 2 is almost always the right choice for modern code.
Complete Practical Example
Here is a practical example that uses multiple search methods together to build a table of contents from a page's headings:
<!DOCTYPE html>
<html>
<head><title>DOM Search Demo</title></head>
<body>
<nav id="toc"></nav>
<article id="content">
<h1>JavaScript Guide</h1>
<h2 id="basics">Basics</h2>
<p>Learn the fundamentals...</p>
<h3>Variables</h3>
<p>Variables store data...</p>
<h3>Functions</h3>
<p>Functions are reusable blocks...</p>
<h2 id="advanced">Advanced Topics</h2>
<p>Deep dive into...</p>
<h3>Closures</h3>
<p>A closure captures variables...</p>
<h3>Prototypes</h3>
<p>Every object has a prototype...</p>
</article>
<script>
// Find all headings inside the article using querySelectorAll
let article = document.getElementById("content");
let headings = article.querySelectorAll("h1, h2, h3");
// Build the table of contents
let toc = document.getElementById("toc");
let tocHTML = "<h2>Table of Contents</h2><ul>";
headings.forEach((heading, index) => {
// Give each heading an ID if it doesn't have one
if (!heading.id) {
heading.id = `heading-${index}`;
}
// Use matches() to determine indentation level
let indent = "";
if (heading.matches("h2")) indent = " ";
if (heading.matches("h3")) indent = " ";
tocHTML += `${indent}<li><a href="#${heading.id}">${heading.textContent}</a></li>`;
});
tocHTML += "</ul>";
toc.innerHTML = tocHTML;
// Add click behavior to smooth-scroll to headings
toc.addEventListener("click", function(event) {
// Use closest() to find the clicked link (even if inner element was clicked)
let link = event.target.closest("a");
if (!link) return;
// Check that the link is inside our TOC using contains()
if (!toc.contains(link)) return;
event.preventDefault();
let targetId = link.getAttribute("href").slice(1);
let targetElement = document.getElementById(targetId);
if (targetElement) {
targetElement.scrollIntoView({ behavior: "smooth" });
}
});
// Log summary
console.log(`Generated TOC with ${headings.length} headings`);
// Demonstrate matches() for filtering
let h2Only = Array.from(headings).filter(h => h.matches("h2"));
console.log("H2 headings:", h2Only.map(h => h.textContent));
// ["Basics", "Advanced Topics"]
</script>
</body>
</html>
This example demonstrates:
getElementByIdfor finding specific elements by IDquerySelectorAllfor finding all headings with a CSS selector groupmatchesfor checking an element's tag levelclosestfor reliable click target identification in event delegationcontainsfor verifying the click happened inside the expected area
Summary
JavaScript provides a complete set of methods for finding elements in the DOM. Here is the full reference:
Single Element Methods:
document.getElementById(id)returns the element with the given ID, ornull. The fastest lookup.element.querySelector(css)returns the first descendant matching the CSS selector, ornull.
Collection Methods:
element.querySelectorAll(css)returns a staticNodeListof all matching descendants.element.getElementsByClassName(class)returns a liveHTMLCollectionof descendants with the class.element.getElementsByTagName(tag)returns a liveHTMLCollectionof descendants with the tag.document.getElementsByName(name)returns a liveNodeListof elements with thenameattribute.
Relationship Checks:
element.matches(css)returnstrueif the element matches the selector.element.closest(css)walks up from the element and returns the first ancestor matching the selector (or the element itself).element.contains(other)returnstrueifotheris a descendant ofelement(or iselementitself).
Practical Guidelines:
- Use
getElementByIdfor fast ID lookups. - Use
querySelectorandquerySelectorAllfor everything else. They accept any CSS selector and return predictable, static results. - Use
closestin event delegation to reliably find the intended target. - Use
matchesto test elements against selectors without searching the DOM. - Use
containsto check parent-child relationships. - Avoid
getElementsByClassNameandgetElementsByTagNameunless you specifically need a live collection.