Tracking DOM Changes with JS Mutation Observers

Build practical MutationObserver patterns for detecting injected content, monitoring attribute changes, and reacting to dynamic DOM updates.

6 min read

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:

javascriptjavascript
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:

javascriptjavascript
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:

javascriptjavascript
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:

javascriptjavascript
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:

javascriptjavascript
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.

javascriptjavascript
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.

Quick reference

PatternKey options
Detect injected elements{ childList: true, subtree: true } on document.body
Wait for an elementDisconnect in callback after finding the element
Watch form fieldsFilter addedNodes for input elements
Sync with attributes{ attributes: true, attributeFilter: [...] }
Clean upAlways call disconnect when the target is removed
Rune AI

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.
RunePowered by Rune AI

Frequently Asked Questions

Can I use MutationObserver to detect when an image finishes loading?

No. MutationObserver detects DOM structure changes, not resource loading events. Use the load event on the image element or an IntersectionObserver to detect when images enter the viewport.

How do I watch for a specific element being added to the page?

Observe document.body with childList and subtree options, then check each added node in the callback to see if it matches your selector using the matches method or by checking its class or id.

Will MutationObserver slow down my page?

MutationObserver is efficient because it batches changes, but observing the entire document with subtree: true and processing every mutation in the callback can impact performance. Limit your observation scope when possible.

Can I undo or prevent a mutation in the callback?

No. The callback fires after the DOM has already changed. You can react to the change by modifying the DOM further, but you cannot prevent the original mutation from happening.

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.