JavaScript Mutation Observer: Complete Tutorial

A complete tutorial on the JavaScript MutationObserver API. Covers observing childList, attributes, and characterData mutations, subtree monitoring, mutation record properties, filtering with attributeFilter, disconnecting and reconnecting, observing dynamic content insertion, building a DOM change logger, undo system, and auto-initializing widgets.

JavaScriptintermediate
15 min read

MutationObserver watches for changes to the DOM tree and fires a callback with detailed records of what changed. It replaces the deprecated Mutation Events (DOMNodeInserted, DOMAttrModified) with a performant, batch-based alternative. This guide covers every configuration option and practical pattern.

Creating a MutationObserver

javascriptjavascript
const observer = new MutationObserver((mutations, observer) => {
  for (const mutation of mutations) {
    console.log("Type:", mutation.type);
    console.log("Target:", mutation.target);
  }
});
 
// Start observing
observer.observe(document.getElementById("app"), {
  childList: true,
  attributes: true,
  characterData: true,
  subtree: true,
});
 
// Stop observing
observer.disconnect();
 
// Get pending mutations without waiting for callback
const pending = observer.takeRecords();

Observation Options

OptionTypeDefaultDescription
childListbooleanfalseWatch for added/removed child nodes
attributesbooleanfalseWatch for attribute changes
characterDatabooleanfalseWatch for text content changes
subtreebooleanfalseObserve all descendants, not just direct children
attributeOldValuebooleanfalseRecord the previous attribute value
characterDataOldValuebooleanfalseRecord the previous text value
attributeFilterstring[](all)Only observe specific attribute names

At least one of childList, attributes, or characterData must be true.

MutationRecord Properties

PropertyTypeDescription
typestring"childList", "attributes", or "characterData"
targetNodeThe node that was mutated
addedNodesNodeListNodes added (childList only)
removedNodesNodeListNodes removed (childList only)
previousSiblingNode or nullNode before added/removed nodes
nextSiblingNode or nullNode after added/removed nodes
attributeNamestring or nullName of changed attribute
attributeNamespacestring or nullNamespace of changed attribute
oldValuestring or nullPrevious value (if configured)

Watching Child Node Changes

javascriptjavascript
const list = document.getElementById("todo-list");
 
const childObserver = new MutationObserver((mutations) => {
  for (const mutation of mutations) {
    if (mutation.type !== "childList") continue;
 
    mutation.addedNodes.forEach((node) => {
      if (node.nodeType === Node.ELEMENT_NODE) {
        console.log("Added:", node.tagName, node.textContent);
      }
    });
 
    mutation.removedNodes.forEach((node) => {
      if (node.nodeType === Node.ELEMENT_NODE) {
        console.log("Removed:", node.tagName, node.textContent);
      }
    });
  }
});
 
childObserver.observe(list, { childList: true });
 
// These changes trigger the callback
list.appendChild(document.createElement("li"));
list.removeChild(list.firstElementChild);
list.innerHTML = "<li>New content</li>";

Watching Attribute Changes

javascriptjavascript
const element = document.getElementById("theme-toggle");
 
const attrObserver = new MutationObserver((mutations) => {
  for (const mutation of mutations) {
    if (mutation.type !== "attributes") continue;
 
    console.log(`Attribute "${mutation.attributeName}" changed`);
    console.log("Old value:", mutation.oldValue);
    console.log("New value:", mutation.target.getAttribute(mutation.attributeName));
  }
});
 
attrObserver.observe(element, {
  attributes: true,
  attributeOldValue: true,
  attributeFilter: ["class", "data-theme", "aria-expanded"],
});
 
// Triggers callback
element.setAttribute("data-theme", "dark");
element.classList.add("active");

Watching Text Content Changes

javascriptjavascript
const textNode = document.getElementById("editable-text").firstChild;
 
const textObserver = new MutationObserver((mutations) => {
  for (const mutation of mutations) {
    if (mutation.type !== "characterData") continue;
 
    console.log("Text changed");
    console.log("Old:", mutation.oldValue);
    console.log("New:", mutation.target.textContent);
  }
});
 
textObserver.observe(textNode, {
  characterData: true,
  characterDataOldValue: true,
});

DOM Change Logger

javascriptjavascript
class DOMChangeLogger {
  constructor(root, options = {}) {
    this.root = typeof root === "string" ? document.querySelector(root) : root;
    this.log = [];
    this.maxEntries = options.maxEntries || 1000;
    this.filter = options.filter || (() => true);
    this.listeners = new Set();
 
    this.observer = new MutationObserver((mutations) => {
      this.processMutations(mutations);
    });
  }
 
  start() {
    this.observer.observe(this.root, {
      childList: true,
      attributes: true,
      characterData: true,
      subtree: true,
      attributeOldValue: true,
      characterDataOldValue: true,
    });
  }
 
  stop() {
    const pending = this.observer.takeRecords();
    if (pending.length) this.processMutations(pending);
    this.observer.disconnect();
  }
 
  processMutations(mutations) {
    for (const mutation of mutations) {
      const entry = {
        type: mutation.type,
        timestamp: Date.now(),
        target: this.describeNode(mutation.target),
      };
 
      switch (mutation.type) {
        case "childList":
          entry.added = [...mutation.addedNodes]
            .filter((n) => n.nodeType === Node.ELEMENT_NODE)
            .map((n) => this.describeNode(n));
          entry.removed = [...mutation.removedNodes]
            .filter((n) => n.nodeType === Node.ELEMENT_NODE)
            .map((n) => this.describeNode(n));
          break;
 
        case "attributes":
          entry.attribute = mutation.attributeName;
          entry.oldValue = mutation.oldValue;
          entry.newValue = mutation.target.getAttribute(mutation.attributeName);
          break;
 
        case "characterData":
          entry.oldValue = mutation.oldValue;
          entry.newValue = mutation.target.textContent;
          break;
      }
 
      if (this.filter(entry)) {
        this.log.push(entry);
        if (this.log.length > this.maxEntries) {
          this.log.shift();
        }
        this.notify(entry);
      }
    }
  }
 
  describeNode(node) {
    if (!node || node.nodeType !== Node.ELEMENT_NODE) {
      return node ? node.textContent?.slice(0, 50) : null;
    }
 
    let desc = node.tagName.toLowerCase();
    if (node.id) desc += `#${node.id}`;
    if (node.className && typeof node.className === "string") {
      desc += `.${node.className.split(" ").join(".")}`;
    }
    return desc;
  }
 
  onChange(callback) {
    this.listeners.add(callback);
    return () => this.listeners.delete(callback);
  }
 
  notify(entry) {
    for (const listener of this.listeners) {
      listener(entry);
    }
  }
 
  getLog() {
    return [...this.log];
  }
 
  clear() {
    this.log = [];
  }
}
 
// Usage
const logger = new DOMChangeLogger("#app", { maxEntries: 500 });
 
logger.onChange((entry) => {
  console.log(`[${entry.type}]`, entry);
});
 
logger.start();

Auto-Initializing Widgets

Automatically initialize components when they are added to the DOM:

javascriptjavascript
class WidgetAutoInitializer {
  constructor() {
    this.registry = new Map();
    this.initialized = new WeakSet();
 
    this.observer = new MutationObserver((mutations) => {
      for (const mutation of mutations) {
        mutation.addedNodes.forEach((node) => {
          if (node.nodeType === Node.ELEMENT_NODE) {
            this.initNode(node);
            node.querySelectorAll("*").forEach((child) => this.initNode(child));
          }
        });
      }
    });
  }
 
  register(selector, initFn) {
    this.registry.set(selector, initFn);
  }
 
  initNode(node) {
    if (this.initialized.has(node)) return;
 
    for (const [selector, initFn] of this.registry) {
      if (node.matches(selector)) {
        initFn(node);
        this.initialized.add(node);
      }
    }
  }
 
  start(root = document.body) {
    // Initialize existing elements
    for (const [selector, initFn] of this.registry) {
      root.querySelectorAll(selector).forEach((el) => {
        if (!this.initialized.has(el)) {
          initFn(el);
          this.initialized.add(el);
        }
      });
    }
 
    // Watch for new elements
    this.observer.observe(root, {
      childList: true,
      subtree: true,
    });
  }
 
  stop() {
    this.observer.disconnect();
  }
}
 
// Usage
const widgets = new WidgetAutoInitializer();
 
widgets.register("[data-tooltip]", (el) => {
  // Initialize tooltip on this element
  console.log("Tooltip initialized:", el.dataset.tooltip);
});
 
widgets.register("[data-dropdown]", (el) => {
  // Initialize dropdown
  console.log("Dropdown initialized:", el.id);
});
 
widgets.start();
 
// Later: dynamically added elements are auto-initialized
document.body.innerHTML += '<button data-tooltip="Click me">Button</button>';

Pausing Mutations During Batch Updates

javascriptjavascript
class PausableMutationObserver {
  constructor(callback, options) {
    this.callback = callback;
    this.options = options;
    this.target = null;
    this.isPaused = false;
    this.pendingMutations = [];
 
    this.observer = new MutationObserver((mutations) => {
      if (this.isPaused) {
        this.pendingMutations.push(...mutations);
      } else {
        this.callback(mutations);
      }
    });
  }
 
  observe(target) {
    this.target = target;
    this.observer.observe(target, this.options);
  }
 
  pause() {
    this.isPaused = true;
  }
 
  resume() {
    this.isPaused = false;
    if (this.pendingMutations.length > 0) {
      this.callback(this.pendingMutations);
      this.pendingMutations = [];
    }
  }
 
  batchUpdate(updateFn) {
    this.pause();
    try {
      updateFn();
    } finally {
      this.resume();
    }
  }
 
  disconnect() {
    this.observer.disconnect();
    this.pendingMutations = [];
  }
}
 
// Usage
const pausable = new PausableMutationObserver(
  (mutations) => console.log(`${mutations.length} mutations`),
  { childList: true, subtree: true }
);
 
pausable.observe(document.getElementById("app"));
 
// Batch many changes into one callback
pausable.batchUpdate(() => {
  for (let i = 0; i < 100; i++) {
    document.getElementById("app").appendChild(document.createElement("div"));
  }
});
// Logs: "100 mutations" (one callback, not 100)
Rune AI

Rune AI

Key Insights

  • Batched microtask delivery: Mutations are collected and delivered in one callback after the current script finishes, not synchronously during DOM changes
  • Three mutation types: childList for added/removed nodes, attributes for attribute changes, characterData for text node changes; enable only what you need
  • subtree for deep observation: Set subtree: true to observe all descendants; without it, only direct children are watched
  • attributeFilter for precision: Specify attributeFilter: ["class", "data-state"] to ignore irrelevant attribute changes and reduce callback noise
  • WeakSet for deduplication: Track initialized elements with a WeakSet to prevent double-processing when new subtrees are added
RunePowered by Rune AI

Frequently Asked Questions

How is MutationObserver different from MutationEvents?

MutationEvents (like `DOMNodeInserted`) fire synchronously during DOM operations, causing severe performance issues. MutationObserver batches mutations and delivers them asynchronously via microtask, after the current script finishes. This makes it safe for complex DOM manipulations. MutationEvents are deprecated and should never be used.

Does MutationObserver detect CSS changes?

Only if the CSS change modifies an attribute (like `style` or `class`). MutationObserver with `attributes: true` will detect `element.style.color = "red"` (which modifies the `style` attribute) and `element.classList.add("active")` (which modifies `class`). It does not detect changes from CSS animations, transitions, or stylesheet rule changes.

Can I observe the entire document?

Yes. Call `observer.observe(document.documentElement, { subtree: true, childList: true })` to watch everything. However, this generates many mutations on dynamic pages. Use `attributeFilter` and targeted `subtree: false` observations when possible to reduce noise.

When does the MutationObserver callback fire?

The callback fires as a microtask after the current script execution completes. If you add 100 elements in a loop, the callback fires once with all 100 mutations batched together, not 100 separate times. This batching is what makes MutationObserver performant. See [the JS event loop architecture complete guide](/tutorials/programming-languages/javascript/the-js-event-loop-architecture-complete-guide) for microtask timing.

How do I observe elements that do not exist yet?

Observe a parent element with `childList: true` and `subtree: true`. When new child elements are added, check them in the callback. The WidgetAutoInitializer pattern above demonstrates this. Use a `WeakSet` to track already-initialized elements and avoid double initialization.

Conclusion

MutationObserver provides performant, batched DOM change detection for child nodes, attributes, and text content. Use it for auto-initializing widgets, logging DOM changes, building undo systems, and reacting to third-party DOM modifications. Prefer targeted observations over document-wide watching, and batch your own updates to minimize mutation records. For viewport-based detection, see JS Intersection Observer API: complete tutorial. For tracking DOM changes in detail, see tracking DOM changes with JS Mutation Observers.