ESC

Type to search the knowledge base.

MutationObserver

Watch DOM mutations without polling — observe options, batching, microtask delivery, and safe patterns for widgets and analytics.

advanced3 min read
  • javascript
  • mutationobserver

You need to know when the DOM changes — a third-party script injects a node, a framework rewrites a subtree, user content appears. Polling innerHTML is wasteful. MutationObserver delivers batched records of what changed, as microtasks, after the current stack clears.

API sketch

const observer = new MutationObserver((mutations, obs) => {
  for (const m of mutations) {
    if (m.type === 'childList') {
      m.addedNodes.forEach((n) => {
        if (n.nodeType === Node.ELEMENT_NODE) {
          // handle new elements
        }
      });
    }
    if (m.type === 'attributes') {
      console.log(m.attributeName, m.oldValue);
    }
  }
});

observer.observe(document.getElementById('root'), {
  childList: true,
  subtree: true,
  attributes: true,
  attributeFilter: ['class', 'data-state'],
  attributeOldValue: true,
  characterData: false,
});

// later
observer.disconnect();

You must call observe with at least one of childList, attributes, or characterData true. Empty options throw.

What you get in a record

Field Meaning
type 'childList', 'attributes', 'characterData'
target Node that was observed / whose children changed
addedNodes / removedNodes NodeLists for childList
attributeName For attributes
oldValue Only if you opted into old value flags

Records are batched. Many DOM writes in one turn → one callback with many records, not one callback per write.

Microtask timing

MutationObserver callbacks are queued as microtasks (similar priority family as promises). That means:

el.appendChild(child);
Promise.resolve().then(() => console.log('promise'));
// observer runs in microtask checkpoint — order vs other microtasks is defined by when they were scheduled
console.log('sync done');

Don’t assume observer runs before every .then; assume “soon after this task, before paint / next macrotask,” and write idempotent handlers.

Practical patterns

React to third-party DOM

function whenAppears(selector, root = document.body) {
  const existing = root.querySelector(selector);
  if (existing) return Promise.resolve(existing);

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

Avoid feedback loops

If your callback mutates the same tree you observe, you can thrash:

// BAD without guards
observer.observe(box, { childList: true, subtree: true });
// callback always appends a node → infinite mutation storm

Disconnect around self-writes, filter by source, or observe a narrower subtree.

Performance

subtree: true on document.body with attributes: true is expensive on busy pages. Prefer:

  • Smallest root that works
  • attributeFilter
  • Disconnect when idle

Not for layout

MutationObserver does not tell you size/visibility. Use ResizeObserver / IntersectionObserver for those.

Interview answer (out loud)

“MutationObserver batches DOM change records and delivers them asynchronously as microtasks. You observe a node with flags for childList, attributes, or characterData, process added/removed nodes, and always disconnect when done. Avoid observing the whole document with every option, and guard against mutating the observed tree inside the callback.”

Further reading

Related guides