JavaScriptMutation Observer

MutationObserver

Some things you cannot polyfill: knowing when the DOM has changed. MutationObserver is the official, batched, low-overhead API for reacting to DOM mutations — new nodes, removed nodes, attribute changes, text changes — whether you made them or somebody else's code did. It replaces the old (and deprecated) Mutation Events with a model that does not fire callbacks during the mutation itself.

The shape of the API

JS
const observer = new MutationObserver((mutations, obs) => {
  for (const m of mutations) {
    console.log(m.type, m.target, m.addedNodes, m.removedNodes);
  }
});

observer.observe(document.body, {
  childList: true,
  subtree: true,
  attributes: true,
  characterData: true,
});

You pick a target and a configuration. The callback fires after the browser is done batching mutations — typically right before paint — with an array of MutationRecords.

The configuration options
  • childList — watch for added or removed children of the target.

  • subtree — extend any watched event to the entire descendant tree, not just direct children.

  • attributes — watch for attribute changes on the target (and descendants, if subtree).

  • attributeOldValue — also record the previous attribute value in the record.

  • attributeFilter — array of attribute names to watch; ignores the rest.

  • characterData — watch for changes to the text inside text nodes.

  • characterDataOldValue — record the previous text.

You must enable at least one type
Calling `observe` with all flags false throws `TypeError`. Enable `childList`, `attributes` or `characterData` (or any combination) for the observation to actually start.
The MutationRecord

JS
// childList example
m.type;            // "childList"
m.target;          // the parent whose children changed
m.addedNodes;      // NodeList
m.removedNodes;    // NodeList
m.previousSibling; // sibling before the changed nodes
m.nextSibling;

// attributes example
m.type;            // "attributes"
m.attributeName;   // "class"
m.attributeNamespace;
m.oldValue;        // present if attributeOldValue: true

// characterData example
m.type;            // "characterData"
m.target;          // the Text node
m.oldValue;        // previous text
Watching for an element to appear

A common use is "do X as soon as element Y exists" — for instance reacting to third-party widgets that inject into the page asynchronously.

waitFor.js

JS
function waitFor(selector, root = document.body) {
  return new Promise((resolve) => {
    const existing = root.querySelector(selector);
    if (existing) return resolve(existing);

    const obs = new MutationObserver(() => {
      const found = root.querySelector(selector);
      if (found) {
        obs.disconnect();
        resolve(found);
      }
    });
    obs.observe(root, { childList: true, subtree: true });
  });
}

const widget = await waitFor("#third-party-widget");
Reacting to attribute changes

theme.js

JS
const obs = new MutationObserver((records) => {
  for (const r of records) {
    if (r.attributeName === "data-theme") {
      console.log("theme is now:", document.documentElement.dataset.theme);
    }
  }
});

obs.observe(document.documentElement, {
  attributes: true,
  attributeFilter: ["data-theme"],
});

The attributeFilter keeps you from being woken up by every unrelated attribute change on the root — much cheaper than filtering inside the callback.

Detecting text edits in a contenteditable

JS
const editor = document.querySelector("[contenteditable]");

const obs = new MutationObserver((records) => {
  for (const r of records) {
    if (r.type === "characterData") {
      console.log("text changed from", r.oldValue, "to", r.target.data);
    }
  }
});

obs.observe(editor, {
  characterData: true,
  characterDataOldValue: true,
  subtree: true,
});
takeRecords and disconnect

Two extra methods are worth knowing.

  • observer.takeRecords() — synchronously returns and clears any queued mutation records that the callback has not yet seen. Useful when you want to "flush" before disconnecting.

  • observer.disconnect() — stop observing. Any pending records are dropped (unless you take them first).

JS
const pending = obs.takeRecords();
obs.disconnect();
handle(pending); // be sure you got everything
Real use cases
  • Re-running a syntax highlighter when a markdown component re-renders.

  • Auto-saving a contenteditable after edits, debounced.

  • Detecting that a third-party script injected a banner / cookie modal and styling it.

  • Polyfilling custom element lifecycle in older browsers.

  • Test utilities that wait for "the DOM looks like this" before asserting.

When NOT to use it
It is a fallback, not a state model
If *your code* is the one mutating the DOM, react to the same trigger that caused the mutation — do not observe yourself. MutationObserver shines when the changes come from somewhere you don't control, or when you genuinely need a guarantee that nothing slipped past.
Performance
Subtree observation over a huge tree with `attributes: true` can be expensive. Scope the target as narrowly as you can, and prefer `attributeFilter` over filtering in the callback.