Tracking DOM Changes with JS Mutation Observers
Build practical MutationObserver patterns for detecting injected content, monitoring attribute changes, and reacting to dynamic DOM updates.
MutationObserver tells you when the DOM changes, but the real skill is knowing what to do with that information. This article covers practical patterns: detecting dynamically injected content, watching for specific elements to appear before acting on them, and building small reactive utilities on top of the observer. Each pattern uses the same API but solves a different real-world problem that polling and event listeners cannot handle efficiently.
Detecting dynamically injected content
Third-party scripts, browser extensions, and ad networks inject elements into your page. To detect and respond to these injections, observe the body with childList and subtree enabled.
Start by creating the observer and filtering added nodes in the callback:
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType !== Node.ELEMENT_NODE) continue;
if (node.matches && node.matches(".ad-banner, .external-widget")) {
console.log("Detected injected element:", node.className);
}
}
}
});The callback filters each added node by checking its type and testing it against a CSS selector. Only element nodes that match the ad banner or external widget classes trigger the console log. This selective filtering prevents the observer from reacting to every text node and comment that enters the DOM.
The callback alone does nothing until you start observing. Call observe on the body with childList and subtree enabled so the callback fires for insertions anywhere in the page:
observer.observe(document.body, { childList: true, subtree: true });The callback filters added nodes to find only elements matching specific selectors. Checking nodeType avoids processing text nodes and comment nodes. The matches method tests whether the element matches a CSS selector without jQuery or manual class checking.
Waiting for a specific element to appear
Sometimes you need to wait for an element that is not yet in the DOM. Instead of polling with setInterval, use a MutationObserver that disconnects itself once the element is found:
function waitForElement(selector, callback) {
const element = document.querySelector(selector);
if (element) return callback(element);
const observer = new MutationObserver(() => {
const found = document.querySelector(selector);
if (found) {
observer.disconnect();
callback(found);
}
});
observer.observe(document.body, { childList: true, subtree: true });
}The function first checks if the element already exists. If not, it creates an observer that watches the entire document.
As soon as the selector matches, it disconnects the observer and calls back with the element. The observer runs only as long as needed.
Watching a dynamic form for new fields
Forms that add or remove fields dynamically need to attach event listeners or validation to the new fields. Instead of manually wiring up each new field when you create it, let an observer handle it.
Start with a small helper that finds the input elements inside a newly added node and wires up validation on each one:
function attachValidation(node) {
if (node.nodeType !== Node.ELEMENT_NODE) return;
const inputs = node.matches("input") ? [node] : node.querySelectorAll("input");
inputs.forEach((input) => input.addEventListener("input", validateField));
}The helper handles both cases: the added node being an input itself, or a wrapper containing one or more inputs. Now create the observer on the form container and start watching with subtree so nested fields are caught:
const form = document.querySelector("#dynamicForm");
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
mutation.addedNodes.forEach(attachValidation);
}
});
observer.observe(form, { childList: true, subtree: true });When a new input field is added to the form, the observer finds it and attaches the validation listener. This works regardless of how the field was added, whether by your code, a library, or a browser extension.
Syncing UI with attribute changes
Some UI libraries and custom elements communicate state through attributes. Watching attribute changes lets you react to state updates from code you do not control.
const panel = document.querySelector("#collapsiblePanel");
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.attributeName === "aria-expanded") {
const isOpen = panel.getAttribute("aria-expanded") === "true";
updatePanelIcon(isOpen);
}
}
});
observer.observe(panel, { attributes: true, attributeFilter: ["aria-expanded"] });The observer watches only the aria-expanded attribute. When it changes, the callback updates a visual indicator. This pattern decouples your UI code from whatever logic toggles the attribute.
Common mistakes
- Observing too broadly. Watching document.body with subtree catches every DOM change on the page. Limit your scope to the smallest container that covers your use case.
- Not filtering by nodeType. Text nodes and comment nodes appear in addedNodes. Always check nodeType before calling matches or accessing properties.
- Forgetting to disconnect. Observers that outlive their target waste memory. Wrap your observer setup in a cleanup function that calls disconnect.
- Recreating observers on every change. Create the observer once and reuse it. The observe method can be called again on a disconnected observer.
Related articles
- JavaScript Mutation Observer: Complete Tutorial -- the full API reference for MutationObserver
- Selecting DOM Elements in JavaScript: Full Guide -- the querySelector and matches methods used in filtering
- Removing HTML Elements Using JavaScript Methods -- handling the removedNodes side of mutations
Quick reference
| Pattern | Key options |
|---|---|
| Detect injected elements | { childList: true, subtree: true } on document.body |
| Wait for an element | Disconnect in callback after finding the element |
| Watch form fields | Filter addedNodes for input elements |
| Sync with attributes | { attributes: true, attributeFilter: [...] } |
| Clean up | Always call disconnect when the target is removed |
Rune AI
Key Insights
- Observe a specific container with subtree: true to catch changes deep in the DOM tree.
- Use addedNodes and removedNodes in the callback to find exactly what changed.
- Filter mutations by checking node names, classes, or attributes before acting on them.
- Disconnect observers when the target is removed to prevent memory leaks.
- Match your observation scope to your needs. Observing document.body is rarely necessary.
Frequently Asked Questions
Can I use MutationObserver to detect when an image finishes loading?
How do I watch for a specific element being added to the page?
Will MutationObserver slow down my page?
Can I undo or prevent a mutation in the callback?
Conclusion
MutationObserver turns DOM changes into events you can react to. Whether detecting injected third-party content, syncing UI state with attribute changes, or building custom elements that respond to their environment, the pattern is the same: observe a target, handle mutations in the callback, and disconnect when done.
More in this topic
Using Decorators for Logging in JS Architecture
Learn how JavaScript decorators wrap class methods to add logging without touching the original code, including setup with Babel and how execution order works.
JavaScript While Loop Explained: A Complete Guide
A while loop repeats code as long as a condition stays true. Learn the syntax, see practical examples, and understand when a while loop is the right choice over a for loop.
JavaScript Loops Tutorial: for, while, do while
Loops let JavaScript repeat code without writing it twice. Learn the three core loop types, what each one does, and when to choose one over another.