Skip to main content

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>
warning

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 window property (like window.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"
tip

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");
caution

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);
}
}
info

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

MethodReturnsLive or Static
getElementByIdSingle element or nullN/A
querySelectorSingle element or nullN/A
querySelectorAllNodeListStatic
getElementsByClassNameHTMLCollectionLive
getElementsByTagNameHTMLCollectionLive
getElementsByNameNodeListLive

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");
tip

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:

SituationBest Method
Find one element by IDgetElementById("id")
Find one element by any CSS selectorquerySelector(".class")
Find all elements by CSS selectorquerySelectorAll(".class")
Need a live-updating collectiongetElementsByClassName("class")
Need all elements of a tag type (live)getElementsByTagName("tag")
Check if an element matches a selectorelement.matches(".selector")
Find the nearest matching ancestorelement.closest(".selector")
Check if one element contains anotherparent.contains(child)
Find form elements by name attributegetElementsByName("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:

  • getElementById for finding specific elements by ID
  • querySelectorAll for finding all headings with a CSS selector group
  • matches for checking an element's tag level
  • closest for reliable click target identification in event delegation
  • contains for 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, or null. The fastest lookup.
  • element.querySelector(css) returns the first descendant matching the CSS selector, or null.

Collection Methods:

  • element.querySelectorAll(css) returns a static NodeList of all matching descendants.
  • element.getElementsByClassName(class) returns a live HTMLCollection of descendants with the class.
  • element.getElementsByTagName(tag) returns a live HTMLCollection of descendants with the tag.
  • document.getElementsByName(name) returns a live NodeList of elements with the name attribute.

Relationship Checks:

  • element.matches(css) returns true if 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) returns true if other is a descendant of element (or is element itself).

Practical Guidelines:

  • Use getElementById for fast ID lookups.
  • Use querySelector and querySelectorAll for everything else. They accept any CSS selector and return predictable, static results.
  • Use closest in event delegation to reliably find the intended target.
  • Use matches to test elements against selectors without searching the DOM.
  • Use contains to check parent-child relationships.
  • Avoid getElementsByClassName and getElementsByTagName unless you specifically need a live collection.