Chapter 10
DOM Manipulation and Browser APIs
Selecting, creating and updating elements, events, delegation and key browser APIs.
10. DOM Manipulation and Browser APIs
The Document Object Model (DOM) is the browser's live, object-based representation of an HTML document. JavaScript can inspect that representation, change it, respond to user input, and ask the browser to perform capabilities such as sharing or measuring an element. Effective DOM programming is not a matter of changing as many nodes as possible; it is a matter of making intentional, accessible changes while respecting the browser's event and rendering models.
This chapter covers selection and construction, event propagation, three observer APIs, user-facing browser capabilities, and the performance consequences of layout changes. Examples use standard browser APIs and can run in a modern browser without a framework.
Overview and Learning Objectives
By the end of this chapter, you will be able to:
- Select DOM elements predictably and distinguish live from static collections.
- Construct and insert nodes, including batching work with
DocumentFragment. - Explain capture, target, and bubble phases and use event delegation safely.
- Use
IntersectionObserver,ResizeObserver, andMutationObserverfor the jobs they fit. - Request clipboard, sharing, and geolocation capabilities with appropriate fallbacks.
- Reduce unnecessary layout work and diagnose DOM behavior in developer tools.
The DOM as a Live Document
When a browser parses HTML, it creates nodes connected in a tree. Elements, text, comments, and the document itself are nodes. JavaScript can access the tree through document and element references. A reference is an object pointing to a particular node; it is not a copy of that node's markup.
<main>
<h1 id="page-title">Field notes</h1>
<ul class="notes">
<li data-category="layout">Grid basics</li>
<li data-category="javascript">DOM events</li>
</ul>
</main>If script runs before the relevant markup has been parsed, a query may return null. Put scripts with defer in the document head, place a script after its markup, or wait for DOMContentLoaded:
document.addEventListener("DOMContentLoaded", () => {
const title = document.querySelector("#page-title");
if (title) {
title.textContent = "Notes for today";
}
});With a deferred external script, the parser finishes building the document before the script executes, so an extra readiness listener is usually unnecessary. Do not assume that document.body or a target exists without controlling when code runs.
Selecting and Inspecting Elements
querySelector() accepts a CSS selector and returns the first matching element, or null. querySelectorAll() returns all current matches in a static NodeList:
const main = document.querySelector("main");
const notes = document.querySelectorAll(".notes li");
if (main) {
console.log(main.tagName); // "MAIN"
}
notes.forEach((note) => {
console.log(note.dataset.category, note.textContent.trim());
});The NodeList from querySelectorAll() is static: it does not update when nodes are later added or removed. Query again when you need a fresh set. By contrast, methods such as getElementsByClassName() and some HTML collections return live collections whose contents update as the document changes. Live collections can be surprising in a loop that mutates the same collection. Convert one to an array when a stable snapshot is needed:
const currentRows = Array.from(document.querySelectorAll("tbody tr"));Selectors are useful for expressing structure, but overly elaborate selectors are difficult to maintain. Prefer stable IDs, classes, and data-* attributes that communicate intent. When a selector value comes from user-controlled text, escape it with CSS.escape() before using it as a selector fragment:
const idFromData = "section:overview";
const section = document.querySelector(`#${CSS.escape(idFromData)}`);Common DOM properties and methods include:
textContentreads or writes all descendant text. Assigning it replaces children with a text node, making it appropriate for untrusted plain text.innerHTMLparses a string as markup. Do not interpolate untrusted values into it; use DOM construction or a vetted sanitizer for intentional user-authored HTML.classList.add(),.remove(),.toggle(), and.contains()manipulate classes without replacing the entire class attribute.datasetexposesdata-*attributes as string-valued properties, such aselement.dataset.noteId.closest(selector)searches the element and its ancestors for the nearest match.matches(selector)tests one element against a selector.parentElement,children,firstElementChild, andnextElementSiblingnavigate element relationships.
Keep a reference only as long as needed. A detached element can remain in memory if application code retains it, even though it is no longer displayed. Check whether a query can fail, especially on reusable pages and optional UI, and make failure behavior explicit rather than dereferencing null.
Creating and Inserting Nodes
Use document.createElement() to make an element and set its properties deliberately. For text, prefer textContent or createTextNode():
const item = document.createElement("li");
item.className = "note";
item.dataset.category = "accessibility";
const label = document.createElement("strong");
label.textContent = "Keyboard testing";
item.append(label);
document.querySelector(".notes")?.append(item);append() accepts nodes and strings; strings become text nodes. appendChild() accepts a node and returns it. Other insertion methods include prepend(), before(), after(), replaceWith(), and remove(). A node can exist in only one place in the tree at a time. Appending a node that already belongs elsewhere moves it; it does not clone it.
Avoid constructing markup by concatenating data into an HTML string:
// Unsafe when title is untrusted:
container.innerHTML += `<li>${title}</li>`;An attacker-controlled value containing markup can become executable content or alter the page. Build elements and assign text instead. When trusted static markup templates are appropriate, keep dynamic data out of HTML parsing contexts or use a security-reviewed sanitizer for rich text.
Batch additions with DocumentFragment
A DocumentFragment is a lightweight container for nodes. It is not rendered as a separate element when inserted; its children are moved into the destination. It makes bulk construction clear and avoids attaching each new item separately during the preparation phase:
function renderNotes(listElement, notes) {
const fragment = document.createDocumentFragment();
for (const note of notes) {
const item = document.createElement("li");
item.className = "note";
item.textContent = note.title;
fragment.append(item);
}
listElement.replaceChildren(fragment);
}replaceChildren() removes old children and inserts the new content. A fragment is especially useful when building a list from data. Modern browsers often optimize repeated DOM operations, so a fragment is not a magic performance guarantee; measure a real bottleneck before optimizing. Its strongest benefits are clear construction and a concise, single insertion point.
If the container may not exist, check before rendering:
const list = document.querySelector("#note-list");
if (list) renderNotes(list, noteData);Treat rendering as a repeatable operation. If a list is refreshed, replace or reconcile its children intentionally; blindly appending every time can duplicate content. Preserve focus and selection when replacing interactive controls, and make state changes perceivable to assistive technology when appropriate.
Events: Capture, Target, and Bubble
An event begins at a target, such as a button receiving a click. For most events that bubble, it travels through three stages:
- Capture: from the outer document ancestors toward the target.
- Target: at the originating element.
- Bubble: from the target back outward through its ancestors.
The default addEventListener() registration listens during the bubble phase. Pass { capture: true } to listen during capture:
const panel = document.querySelector(".panel");
panel?.addEventListener(
"click",
(event) => {
console.log("Capture phase:", event.target);
},
{ capture: true },
);The event's target is the originating node; currentTarget is the node whose listener is currently running. In nested markup, a click on an icon inside a button has the icon as target and the button as currentTarget. event.stopPropagation() prevents further propagation, while event.preventDefault() cancels a cancelable browser default such as following a link. These methods solve different problems. Avoid stopping propagation casually: it can break other components that rely on the event.
A listener can be configured with once: true to remove itself after one call, or signal to tie its lifetime to an AbortController. Remove manually with the same function reference and matching capture setting when not using those options:
const controller = new AbortController();
window.addEventListener("resize", updateLayout, {
signal: controller.signal,
});
// When the associated component is disposed:
controller.abort();This avoids forgotten global listeners, which can retain component state or invoke obsolete behavior.
Event delegation
Delegation uses one listener on a stable ancestor to handle events from matching descendants. It is valuable for dynamic lists because newly inserted buttons work without attaching an individual listener to every item:
<ul id="task-list">
<li data-task-id="a1">
Write overview <button type="button" data-action="remove">Remove</button>
</li>
</ul>const taskList = document.querySelector("#task-list");
taskList?.addEventListener("click", (event) => {
if (!(event.target instanceof Element)) return;
const button = event.target.closest('button[data-action="remove"]');
if (!button || !taskList.contains(button)) return;
const row = button.closest("[data-task-id]");
if (!row) return;
console.log("Remove task:", row.dataset.taskId);
row.remove();
});The containment check ensures that a matching button belongs to this list rather than a nested component. Delegation is appropriate when events bubble and descendants share behavior. It is not a universal replacement for direct listeners: focus events do not bubble in their original form (though focusin does), and each control may need a distinct listener or lifecycle.
For accessible controls, use native buttons and links rather than clickable generic elements. Native controls already support keyboard activation and convey semantics. If handling form submission, listen for the form's submit event, call preventDefault() only when JavaScript will handle the submission, and validate both in the browser and on the server.
Observing Intersection, Size, and Mutations
Observer APIs let the browser notify code about changes without continuously polling. They are asynchronous and fit distinct questions. Always disconnect or unobserve targets when observation is no longer needed.
IntersectionObserver: visibility and lazy loading
IntersectionObserver reports when a target intersects a root viewport or ancestor. It is useful for lazy-loading offscreen images and starting animations when content becomes visible. It is usually preferable to a scroll handler that repeatedly calculates every element's position.
<img
class="lazy-image"
src="/images/placeholder.webp"
data-src="/images/mountain.webp"
alt="Mountain ridge at sunrise"
width="1200"
height="800"
>const images = document.querySelectorAll("img[data-src]");
if ("IntersectionObserver" in window) {
const observer = new IntersectionObserver(
(entries, currentObserver) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const image = entry.target;
image.src = image.dataset.src;
image.removeAttribute("data-src");
currentObserver.unobserve(image);
}
},
{ rootMargin: "200px 0px", threshold: 0 },
);
images.forEach((image) => observer.observe(image));
} else {
images.forEach((image) => {
image.src = image.dataset.src;
image.removeAttribute("data-src");
});
}rootMargin expands or contracts the effective root bounds, so a positive margin can load an image shortly before it appears. threshold controls the fraction of visibility that triggers an entry. Provide dimensions or an aspect-ratio to prevent layout shifts. For most ordinary images, the native loading="lazy" attribute is simpler and should be preferred; use an observer when custom behavior is needed. Do not lazy-load an image that is essential to the initial viewport, such as a likely Largest Contentful Paint image.
ResizeObserver: element dimensions
ResizeObserver notifies code when an element's content or border box changes size. Unlike a window resize listener, it also responds when a parent layout, font, or content change resizes the element:
const card = document.querySelector(".chart-card");
if (card && "ResizeObserver" in window) {
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const width = entry.contentRect.width;
card.classList.toggle("is-compact", width < 360);
}
});
observer.observe(card);
}Be careful not to create a resize feedback loop: changing the observed element's dimensions in response to its own measured size may trigger repeated notifications. Prefer CSS container queries for visual layout changes; use ResizeObserver when JavaScript genuinely needs the measurement, such as adapting a canvas's drawing buffer.
MutationObserver: DOM changes
MutationObserver reports changes to a node subtree, including child additions, attribute changes, or text changes. It is useful when integrating with third-party widgets or monitoring a region whose updates your code does not control:
const region = document.querySelector("#external-widget");
if (region) {
const observer = new MutationObserver((records) => {
for (const record of records) {
if (record.type === "childList") {
console.log("Added nodes:", record.addedNodes.length);
} else if (record.type === "attributes") {
console.log("Changed attribute:", record.attributeName);
}
}
});
observer.observe(region, {
childList: true,
subtree: true,
attributes: true,
});
// Later, when monitoring is no longer needed:
// observer.disconnect();
}A mutation observer sees that the DOM changed, not necessarily why it changed. Do not observe the entire document and perform expensive work for every record unless necessary. Filter records to the smallest useful subtree and relevant attributes. If your code owns the update, call its update function directly instead of observing your own mutations.
Clipboard, Web Share, and Geolocation
Browser capabilities are permission-sensitive and may require a secure context (HTTPS or localhost), user activation, and user consent. Code must handle rejection and unavailable APIs. Do not request more data than a feature needs.
Clipboard
The asynchronous Clipboard API can write text after a clear user action:
async function copyLink(url) {
if (!navigator.clipboard?.writeText) {
throw new Error("Clipboard writing is not available.");
}
await navigator.clipboard.writeText(url);
}
document.querySelector("#copy-link")?.addEventListener("click", async () => {
try {
await copyLink(location.href);
announce("Link copied.");
} catch {
announce("Could not copy the link. Select and copy it manually.");
}
});Permission and browser rules vary; calling the API outside a user gesture may fail. Avoid silently claiming success before the promise resolves. A fallback should be accessible and usable, not a hidden input hack that unexpectedly steals focus.
Web Share
The Web Share API opens the operating system's share sheet when supported:
async function shareArticle(article) {
const payload = {
title: article.title,
text: article.summary,
url: article.url,
};
if (navigator.share && (!navigator.canShare || navigator.canShare(payload))) {
await navigator.share(payload);
return;
}
await navigator.clipboard.writeText(article.url);
}The user may cancel sharing, so cancellation is not necessarily an application error. Provide a visible alternative, such as a copy-link button. navigator.canShare() is optional and can help check support for a particular payload; the final call can still reject.
Geolocation
Geolocation requires a secure context and explicit permission. Request it in response to a user action, explain why the location is needed, and offer a manual alternative:
function locateUser() {
if (!navigator.geolocation) {
showMessage("Location is unavailable. Enter a place instead.");
return;
}
navigator.geolocation.getCurrentPosition(
(position) => {
const { latitude, longitude } = position.coords;
showMessage(`Approximate coordinates: ${latitude}, ${longitude}`);
},
(error) => {
const messages = {
1: "Location permission was denied.",
2: "Your location could not be determined.",
3: "The location request timed out.",
};
showMessage(messages[error.code] ?? "Location is unavailable.");
},
{ enableHighAccuracy: false, timeout: 8000, maximumAge: 60_000 },
);
}Coordinates are sensitive personal data. Do not persist or transmit them without a clear need and an understandable explanation. Choose accuracy and timeout options appropriate to the task; high accuracy can consume more battery and is not automatically better.
Rendering Cost: Reflow and Repaint
The browser turns DOM and CSS into pixels through style calculation, layout, paint, and compositing. A reflow (often called layout) recalculates geometry, for example after changing a width that affects surrounding elements. A repaint redraws pixels when appearance changes without necessarily changing geometry, such as changing a background color. Browser terminology and internal optimizations vary, but frequent forced layout and large invalidated regions can still be expensive.
Reading layout-dependent values such as offsetWidth, getBoundingClientRect(), or some computed styles after a write may force synchronous layout so the browser can return current geometry. Repeatedly alternating writes and reads is called layout thrashing:
// Avoid: each iteration may force layout after the previous write.
for (const card of cards) {
card.style.width = `${container.offsetWidth / 2}px`;
}Read once, calculate, then write:
const width = container.clientWidth / 2;
for (const card of cards) {
card.style.width = `${width}px`;
}For ordinary layout, prefer CSS and responsive rules over JavaScript-driven widths. When an update is visual and can wait for the next frame, requestAnimationFrame() can schedule it close to painting. Avoid using timers as a substitute for frame scheduling. Animate transform and opacity when suitable because they can often be composited without recalculating layout; this is not a guarantee that every animation is free.
Keep DOM subtrees no larger than the UI needs, update only changed regions, and measure before optimizing. In browser developer tools, use the Performance panel to record an interaction and inspect scripting, style recalculation, layout, and paint. The Rendering tools can highlight paint flashing or layout shifts. A slow operation may instead be JavaScript computation, image decoding, network work, or a long task.
Debugging and Best Practices
- Confirm the element exists and that the script executes after parsing. Inspect the Console for
nullaccess errors and syntax errors. - In Elements/Inspector, verify the actual DOM, attributes, and classes. The Elements view shows the current live tree, which may differ from the source HTML.
- Use event-listener inspection in developer tools to locate duplicate listeners. Check whether handlers are registered once or on every render.
- Log
event.target,event.currentTarget, andevent.eventPhasewhile learning propagation. Remove temporary logging after diagnosis. - Use the Network panel to confirm lazy images actually load and inspect failed requests.
- Disconnect observers when targets are removed or their component is disposed. Observe the smallest subtree or target necessary.
- Use semantic HTML, preserve focus, announce meaningful asynchronous status changes, and keep keyboard interaction native.
- Treat text from a user, URL, API, or storage as untrusted. Assign plain text with
textContent; never insert it intoinnerHTMLwithout an explicit, vetted sanitization strategy. - Check API availability and rejection paths. Permission prompts are not guaranteed to be accepted.
- Avoid frequent forced layout and repeated full-list replacement in hot paths. Profile a representative interaction before making performance claims.
Hands-on Exercise 1: Build a Delegated Task List
Create an interactive list that adds tasks and removes a task using one delegated listener. The task text must be displayed as text, not interpreted as HTML.
Steps
- Add a form with a labeled input and a submit button, then add an empty
<ul>. - On form submission, prevent the default navigation and trim the input.
- Ignore an empty value.
- Create each task row and remove button with
createElement(). - Put a unique task identifier on the row and append it.
- Add one click listener to the list that finds a remove button with
closest(). - Confirm that task text such as
<img src=x onerror=alert(1)>appears literally and does not execute.
Complete solution
<form id="task-form">
<label for="task-input">New task</label>
<input id="task-input" name="task" required>
<button type="submit">Add task</button>
</form>
<ul id="task-list" aria-label="Tasks"></ul>
<p id="task-status" role="status" aria-live="polite"></p>const form = document.querySelector("#task-form");
const input = document.querySelector("#task-input");
const list = document.querySelector("#task-list");
const status = document.querySelector("#task-status");
let nextId = 1;
form.addEventListener("submit", (event) => {
event.preventDefault();
const title = input.value.trim();
if (!title) {
input.focus();
return;
}
const row = document.createElement("li");
row.dataset.taskId = String(nextId++);
const text = document.createElement("span");
text.textContent = title;
const remove = document.createElement("button");
remove.type = "button";
remove.dataset.action = "remove";
remove.textContent = "Remove";
remove.setAttribute("aria-label", `Remove task: ${title}`);
row.append(text, " ", remove);
list.append(row);
input.value = "";
input.focus();
status.textContent = "Task added.";
});
list.addEventListener("click", (event) => {
if (!(event.target instanceof Element)) return;
const button = event.target.closest('button[data-action="remove"]');
if (!button || !list.contains(button)) return;
button.closest("li[data-task-id]")?.remove();
status.textContent = "Task removed.";
});The input's required constraint provides a basic native check, while trimming catches whitespace-only entries. textContent ensures the title remains plain text. A role="status" region politely announces updates without moving focus after every removal.
Hands-on Exercise 2: Lazy Load a Gallery
Build a gallery that starts with a placeholder image, loads the real source shortly before the image is visible, and has a fallback for browsers without IntersectionObserver.
Steps
- Give each image a useful
alt, fixed dimensions, placeholdersrc, and realdata-src. - Select only images that still have
data-src. - Create one observer with a positive
rootMargin. - On intersection, set
src, removedata-src, and unobserve the image. - If the API is unavailable, assign every real source immediately.
- Test with the Network panel throttled and scroll slowly. Confirm the first viewport's important image is not unnecessarily deferred.
Complete solution
<div class="gallery">
<img src="/images/blurred-placeholder.webp"
data-src="/images/river.webp"
alt="River flowing through a wooded valley"
width="960" height="640">
<img src="/images/blurred-placeholder.webp"
data-src="/images/forest.webp"
alt="Sunlight among evergreen trees"
width="960" height="640">
</div>.gallery {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
gap: 1rem;
}
.gallery img {
display: block;
width: 100%;
height: auto;
aspect-ratio: 3 / 2;
object-fit: cover;
}const pendingImages = document.querySelectorAll("img[data-src]");
function loadImage(image) {
const source = image.dataset.src;
if (!source) return;
image.src = source;
image.removeAttribute("data-src");
}
if ("IntersectionObserver" in window) {
const imageObserver = new IntersectionObserver(
(entries, observer) => {
for (const entry of entries) {
if (entry.isIntersecting) {
loadImage(entry.target);
observer.unobserve(entry.target);
}
}
},
{ rootMargin: "250px 0px" },
);
pendingImages.forEach((image) => imageObserver.observe(image));
} else {
pendingImages.forEach(loadImage);
}The dimensions and aspect ratio reserve space before download, reducing layout shift. In a production gallery, keep above-the-fold images eager, handle image load errors with an accessible fallback, and ensure the placeholder is not mistaken for the final image by assistive technology.
Chapter Summary
The DOM is a live tree: query it deliberately, build content safely, and use fragments when bulk construction improves clarity. Events pass through capture, target, and bubble phases; delegation is useful for dynamic descendants when you check the actual target and containment. Intersection, resize, and mutation observers solve different observation problems, and should be scoped and cleaned up. Clipboard, sharing, and location APIs are asynchronous or permission-controlled, so explain their purpose and handle unavailability. Finally, minimize layout thrashing, rely on CSS for visual layout, and profile before optimizing.