JavaScript Mutation Observer: Complete Tutorial

Learn how to watch for DOM changes with the MutationObserver API. Detect added nodes, attribute changes, and text updates in real time.

5 min read

The MutationObserver API watches a DOM element and fires a callback whenever the element's children, attributes, or text content change. Instead of polling the DOM with setInterval or listening to dozens of individual events, you declare what to watch and receive a batch of changes in one efficient, asynchronous callback.

javascriptjavascript
const observer = new MutationObserver((mutations) => {
  for (const mutation of mutations) {
    console.log(mutation.type, mutation.target);
  }
});
 
observer.observe(document.querySelector("#container"), {
  childList: true,
  subtree: true,
});

The observer now watches the element with id "container" and all its descendants for any child additions or removals. Any time a child element is added or removed anywhere inside that tree, the callback fires with detailed mutation records describing exactly what changed.

How MutationObserver works

A MutationObserver runs asynchronously. When DOM changes happen, the browser queues them and calls your callback once, passing all the changes that occurred since the last callback. This batching is what makes MutationObserver efficient.

MutationObserver batching flow

Multiple DOM changes that happen in the same synchronous block are collected into one callback invocation. You do not pay the cost of a function call per change.

Observing child list changes

The childList option watches for elements being added to or removed from the target. This is the most common use case, especially for detecting dynamically injected content.

javascriptjavascript
const list = document.querySelector("#list");
const observer = new MutationObserver((mutations) => {
  for (const mutation of mutations) {
    mutation.addedNodes.forEach((node) => {
      console.log("Added:", node.textContent);
    });
    mutation.removedNodes.forEach((node) => {
      console.log("Removed:", node.textContent);
    });
  }
});
 
observer.observe(list, { childList: true });

Each mutation record has an addedNodes NodeList and a removedNodes NodeList. You can iterate them to find exactly which elements appeared or disappeared.

Observing attribute changes

Set attributes to true to detect when attributes like class, id, or data-* change on the target element. Use the attributeFilter array to watch only specific attributes.

javascriptjavascript
const button = document.querySelector("#toggleButton");
const observer = new MutationObserver((mutations) => {
  for (const mutation of mutations) {
    const oldValue = mutation.oldValue;
    const newValue = button.getAttribute(mutation.attributeName);
    console.log(`${mutation.attributeName}: ${oldValue} -> ${newValue}`);
  }
});
 
observer.observe(button, {
  attributes: true,
  attributeFilter: ["disabled", "aria-expanded"],
  attributeOldValue: true,
});

Setting attributeOldValue to true includes the previous value in each mutation record. Without it, you only know that the attribute changed, not what it was before.

Observing text content changes

The characterData option watches for text node changes. Set subtree to true to catch text changes anywhere inside the observed tree.

javascriptjavascript
const editor = document.querySelector("#editor");
const observer = new MutationObserver((mutations) => {
  console.log("Text changed in the editor");
});
 
observer.observe(editor, {
  characterData: true,
  subtree: true,
});

This fires whenever text inside the editor or any of its descendants changes, including contenteditable edits and programmatic textContent assignments.

Disconnecting the observer

Call disconnect to stop observing. This is important when the target element is removed from the DOM or when your component unmounts.

javascriptjavascript
observer.disconnect();

After disconnecting, the callback will never fire again. You can call observe again later on the same observer to resume watching a different element.

Mutations that already happened but have not reached the callback yet are still sitting in the observer's internal queue. Call takeRecords right before disconnecting to collect them instead of losing them:

javascriptjavascript
const pendingMutations = observer.takeRecords();
observer.disconnect();
console.log(pendingMutations.length);

takeRecords empties the queue and returns whatever mutation records were waiting, so you can process that last batch yourself instead of letting the browser discard it.

Common mistakes

  • Forgetting subtree when watching a container with dynamic content. Without it, only direct children trigger the callback.
  • Not disconnecting observers when elements are removed. Orphaned observers keep references and prevent garbage collection.
  • Using MutationObserver for performance-sensitive animations. It fires asynchronously, which means there is a frame of delay between the DOM change and your callback.
  • Observing the entire document body with subtree. This catches every DOM change on the page and can noticeably impact performance.

Quick reference

OperationCode
Create observernew MutationObserver(callback)
Start observingobserver.observe(target, { childList: true })
Watch attributes{ attributes: true, attributeFilter: ["class"] }
Watch text changes{ characterData: true, subtree: true }
Get old attribute value{ attributeOldValue: true }
Stop observingobserver.disconnect()
Get pending recordsobserver.takeRecords()
Rune AI

Rune AI

Key Insights

  • Create a MutationObserver with a callback that receives an array of MutationRecord objects.
  • Call observe(target, options) to start watching with childList, attributes, or characterData flags.
  • Use subtree: true to watch all descendants, not just direct children.
  • The callback receives batched changes, making it far more performant than the old Mutation Events.
  • Call disconnect() when done observing to free resources.
RunePowered by Rune AI

Frequently Asked Questions

How is MutationObserver different from Mutation Events?

Mutation Events (DOMNodeInserted, etc.) were synchronous and fired on every single change, causing major performance problems. MutationObserver is async and batches changes into a single callback, making it far more efficient.

Does MutationObserver work on elements added dynamically?

Yes. If you observe a parent container with childList: true and subtree: true, the observer fires for any descendant added or removed, including deeply nested elements injected by third-party scripts.

Can I observe changes to a specific attribute only?

Yes. Pass attributeFilter: ['class', 'data-active'] in the options to watch only those attributes. Without a filter, all attribute changes trigger the callback.

When should I disconnect a MutationObserver?

Call disconnect() when the observed element is removed from the DOM or when you no longer need to watch it. Leaving orphaned observers attached to removed elements wastes memory.

Conclusion

MutationObserver gives you a performant way to react to DOM changes without polling. Create an observer with a callback, call observe with the target and options, and your callback fires with a batch of MutationRecord objects whenever the DOM changes in ways you specify.