Observing changes with MutationObserver

Watch for added nodes, attribute changes and text edits, understand the record batching, and avoid the observer loop.

Starting an observer

const target = document.querySelector('#results');

const observer = new MutationObserver((records) => {
  for (const record of records) {
    if (record.type === 'childList') {
      for (const node of record.addedNodes) {
        if (node.nodeType === Node.ELEMENT_NODE) {
          console.log('added', node.className);
        }
      }
      for (const node of record.removedNodes) {
        node.isConnected;   // false once detached
      }
    }

    if (record.type === 'attributes') {
      console.log(record.attributeName, record.oldValue, record.target);
    }

    if (record.type === 'characterData') {
      console.log('text changed in', record.target.parentNode);
    }
  }
});

observer.observe(target, {
  childList: true,
  subtree: true,             // include descendants, not just direct children
  attributes: true,
  attributeFilter: ['class', 'data-state'],   // limit what wakes you up
  attributeOldValue: true,
  characterData: true,
});
  • Records are batched into a microtask; one callback can describe several changes.
  • Without subtree: true you only see direct children of the target.
  • attributeFilter is the difference between a useful observer and one that fires on every hover style change.
  • attributeOldValue requires attributes: true, and characterData needs subtree: true to be useful on a container.
⚠️
An observer that mutates what it observes calls itself indefinitely. If your callback writes to the same subtree it watches, either disconnect first and re-observe afterwards, or guard with a flag that suppresses the next batch.

Breaking the loop

// wrong: the callback keeps retriggering itself
const bad = new MutationObserver(() => {
  document.body.setAttribute('data-height', String(document.body.offsetHeight));
});
bad.observe(document.body, { attributes: true, attributeFilter: ['data-height'] });

// right: ignore records this observer caused
let writing = false;

const good = new MutationObserver((records) => {
  if (writing) return;
  writing = true;

  document.body.setAttribute('data-height', String(document.body.offsetHeight));

  queueMicrotask(() => { writing = false; });
});

good.observe(document.body, {
  childList: true,
  subtree: true,
  attributes: true,
  attributeFilter: ['style', 'class'],
});
// disconnecting and reconnecting around a bulk change
observer.disconnect();
container.replaceChildren(...buildAll());
observer.observe(container, { childList: true, subtree: true });

// or drain the queue without a callback
const pending = observer.takeRecords();
observer.disconnect();
Option / methodPurpose
childListNodes added or removed from the target
subtreeExtend the watch to all descendants
attributes + attributeFilterWatch named attributes only
characterDataWatch text node content
takeRecords()Drain pending records now, without waiting
disconnect()Stop observing, discard pending records

What it is genuinely for

// enhance markup that third-party code injects
const enhancer = new MutationObserver((records) => {
  for (const record of records) {
    for (const node of record.addedNodes) {
      if (node.nodeType !== Node.ELEMENT_NODE) continue;
      node.querySelectorAll('[data-tooltip]').forEach(attachTooltip);
      if (node.matches('[data-tooltip]')) attachTooltip(node);
    }
  }
});
enhancer.observe(document.querySelector('#chat'), { childList: true, subtree: true });
  • Integrating with markup produced by a widget you do not control, where there is no event to listen for.
  • Detecting an undesired change - a production guard that reports when something rewrites a container it should not.
  • Serialising state to storage when a subtree changes, using a debounced callback.
  • Auto-sizing a textarea after any content change, which no input event covers.
// debounced save driven by DOM changes
let timer;
const saver = new MutationObserver(() => {
  clearTimeout(timer);
  timer = setTimeout(() => save(editor.innerHTML), 500);
});
saver.observe(editor, { childList: true, subtree: true, characterData: true });

The honest caveat: in your own code, prefer an explicit call at the point of change. An observer is a listener for changes you did not make. Using it to route your own updates turns a direct call into an asynchronous hop that is harder to follow and much harder to test.

FAQ

Does MutationObserver fire once per change?
No. Records are queued and delivered together in a microtask, so one callback can contain many records. Handle the array rather than assuming a single change, and expect the count to differ between browsers and between debug and production builds.
Can it observe a shadow root?
Yes - call observe() on the shadow root rather than the host element. Observing the host does not cross the shadow boundary, so changes inside the shadow tree are invisible to it.

Templates, cloning and efficient rendering Web Components: custom elements and shadow DOM

Last refreshed 2026-09-18.