JavaScript MutationObserver: Watch the DOM Like a Pro

deep-diveJuly 27, 2026· 9 min read

You've probably been there: a third-party script injects elements into your page, and you have no idea when or where. Or you're building a testing library and need to know when a specific element appears in the DOM. Or you're writing a browser extension that needs to react to page changes.

Polling with setInterval and querySelector is ugly and wasteful. There's a better way — MutationObserver.

MutationObserver is the browser's built-in API for watching DOM changes. It fires callbacks when elements are added, removed, attributes change, or text content updates — all without polling, all highly optimised by the browser engine.

Let me walk through the patterns that actually matter in production.

The Basics

Here's the minimum viable MutationObserver:

// 1. Pick the element to watch
const target = document.getElementById('app');

// 2. Create an observer with a callback
const observer = new MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
    console.log('Something changed!', mutation.type);
  });
});

// 3. Start observing
observer.observe(target, {
  childList: true,     // watch for added/removed children
  subtree: true,       // watch the entire subtree, not just direct children
});

// Later, when you're done
observer.disconnect();

Three steps: create, observe, disconnect. The power is in what you can observe and how efficiently the browser delivers those changes.

What Can You Observe?

The options object controls everything:

observer.observe(target, {
  childList: true,      // direct children added/removed
  subtree: true,        // include all descendants too
  attributes: true,     // attribute changes (class, src, data-*, etc.)
  attributeFilter: ['class', 'data-state'], // only specific attributes
  characterData: true,  // text content changes
  characterDataOldValue: true,  // include the previous text value
  attributeOldValue: true,      // include previous attribute values
});

The childList and subtree combination is the most common — it watches an entire subtree for structural changes. But the filters are what make MutationObserver practical for real applications.

Rule of thumb: Observe as narrowly as possible. subtree: true on <body> will fire on every single DOM change on the page. Use attributeFilter and scope your target element tightly.

Use Case 1: Detect When an Element Appears

This is the #1 reason I reach for MutationObserver. You need to know when a specific element gets added to the page — maybe it's rendered by a framework, injected by a script, or loaded asynchronously.

function waitForElement(selector, target = document.body) {
  return new Promise((resolve) => {
    // Check if it's already there
    const existing = target.querySelector(selector);
    if (existing) {
      resolve(existing);
      return;
    }

    const observer = new MutationObserver((mutations) => {
      for (const mutation of mutations) {
        for (const node of mutation.addedNodes) {
          if (node.nodeType !== Node.ELEMENT_NODE) continue;

          // Check the added node itself
          if (node.matches?.(selector)) {
            observer.disconnect();
            resolve(node);
            return;
          }

          // Check descendants of the added node
          const found = node.querySelector?.(selector);
          if (found) {
            observer.disconnect();
            resolve(found);
            return;
          }
        }
      }
    });

    observer.observe(target, { childList: true, subtree: true });
  });
}

// Usage: wait for a modal to appear, then do something
const modal = await waitForElement('.modal-dialog');
modal.classList.add('custom-styling');

This pattern replaces setInterval(() => document.querySelector(...), 100) entirely. It's instant, efficient, and self-cleaning.

With a Timeout

Don't wait forever — add a timeout for robustness:

function waitForElement(selector, options = {}) {
  const { target = document.body, timeout = 10000 } = options;

  return new Promise((resolve, reject) => {
    const existing = target.querySelector(selector);
    if (existing) return resolve(existing);

    const timer = setTimeout(() => {
      observer.disconnect();
      reject(new Error(`Element "${selector}" not found within ${timeout}ms`));
    }, timeout);

    const observer = new MutationObserver((mutations) => {
      for (const mutation of mutations) {
        for (const node of mutation.addedNodes) {
          if (node.nodeType !== Node.ELEMENT_NODE) continue;
          if (node.matches?.(selector) || node.querySelector?.(selector)) {
            clearTimeout(timer);
            observer.disconnect();
            resolve(node.matches?.(selector) ? node : node.querySelector(selector));
            return;
          }
        }
      }
    });

    observer.observe(target, { childList: true, subtree: true });
  });
}

Use Case 2: React to Attribute Changes

Want to know when a class changes, a data attribute updates, or a element gets toggled? Watch attributes:

const sidebar = document.getElementById('sidebar');

const observer = new MutationObserver((mutations) => {
  for (const mutation of mutations) {
    if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
      const isOpen = sidebar.classList.contains('open');
      console.log(`Sidebar is now ${isOpen ? 'open' : 'closed'}`);
      // Update something else in response
      document.body.style.overflow = isOpen ? 'hidden' : '';
    }
  }
});

observer.observe(sidebar, {
  attributes: true,
  attributeFilter: ['class'],
  attributeOldValue: true,
});

The attributeOldValue: true option gives you mutation.oldValue — the previous attribute value. This is useful for tracking state transitions:

if (mutation.oldValue === 'closed' && target.className === 'open') {
  analytics.track('sidebar_opened');
}

Use Case 3: Track Third-Party Script Injections

Ad scripts, analytics pixels, chat widgets — they all inject elements you didn't ask for. MutationObserver lets you track and optionally clean them up:

function watchForeignScripts() {
  const observer = new MutationObserver((mutations) => {
    for (const mutation of mutations) {
      for (const node of mutation.addedNodes) {
        if (node.tagName === 'SCRIPT') {
          const src = node.src || '(inline)';
          console.log(`Script added: ${src}`);
          // Optionally block it
          // node.remove();
        }
        if (node.tagName === 'IFRAME') {
          console.log(`Iframe injected: ${node.src}`);
        }
        if (node.tagName === 'IMG' && node.src?.includes('tracking')) {
          console.log(`Tracking pixel: ${node.src}`);
        }
      }
    }
  });

  observer.observe(document.documentElement, {
    childList: true,
    subtree: true,
  });

  return observer;
}

const scriptWatcher = watchForeignScripts();
// scriptWatcher.disconnect() when done

This is exactly how content blockers and privacy extensions work — they watch for specific element types and remove them before they execute.

Use Case 4: Auto-Save on Content Changes

Building a rich text editor or contenteditable component? Watch for text changes:

const editor = document.getElementById('editor');

let saveTimer = null;
const observer = new MutationObserver(() => {
  // Debounce saves
  clearTimeout(saveTimer);
  saveTimer = setTimeout(() => {
    const content = editor.innerHTML;
    localStorage.setItem('draft', content);
    console.log('Draft saved');
  }, 1000);
});

observer.observe(editor, {
  childList: true,        // paragraphs added/removed
  characterData: true,    // text changes
  subtree: true,          // catch changes anywhere inside
  characterDataOldValue: true,
});

The characterData: true option watches text nodes for content changes — essential for contenteditable elements where typing doesn't add/remove elements, just changes text.

Use Case 5: Testing and Assertions

If you're building testing utilities, MutationObserver is how you assert that DOM changes happened:

function assertAdded(callback) {
  return new Promise((resolve) => {
    const observer = new MutationObserver((mutations) => {
      const addedNodes = mutations.flatMap(m => [...m.addedNodes]);
      if (addedNodes.length > 0) {
        observer.disconnect();
        callback(addedNodes, mutations);
        resolve({ addedNodes, mutations });
      }
    });

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

// In a test
test('button click opens modal', async () => {
  const button = document.querySelector('#open-modal');
  const resultPromise = assertAdded((nodes) => {
    assert.ok(nodes.some(n => n.classList?.contains('modal')));
  });

  button.click();
  await resultPromise;
});

Understanding Mutation Records

Each mutation in the callback is a MutationRecord with these properties:

{
  type: 'childList',        // 'childList' | 'attributes' | 'characterData'
  target: <element>,        // the element that changed
  addedNodes: NodeList,     // nodes added (for childList)
  removedNodes: NodeList,   // nodes removed (for childList)
  previousSibling: <node>,  // sibling before the change
  nextSibling: <node>,      // sibling after the change
  attributeName: 'class',   // which attribute changed (for attributes)
  oldValue: 'old-class',    // previous value (if attributeOldValue/characterDataOldValue)
}

The key insight: target is not the added/removed node — it's the parent element where the change happened. The actual nodes are in addedNodes and removedNodes.

const observer = new MutationObserver((mutations) => {
  for (const mutation of mutations) {
    if (mutation.type === 'childList') {
      console.log(`Parent: ${mutation.target.tagName}`);
      mutation.addedNodes.forEach(node => {
        console.log(`  + Added: ${node.nodeName}`);
      });
      mutation.removedNodes.forEach(node => {
        console.log(`  - Removed: ${node.nodeName}`);
      });
    }
  }
});

Batching: Mutations Are Delivered Asynchronously

MutationObserver doesn't fire synchronously on every change. The browser batches mutations and delivers them together, after the current microtask:

const list = document.getElementById('list');
const observer = new MutationObserver((mutations) => {
  console.log(`Received ${mutations.length} mutations`);
});

observer.observe(list, { childList: true });

// These three changes produce ONE callback with 3 mutation records
list.appendChild(document.createElement('li'));
list.appendChild(document.createElement('li'));
list.appendChild(document.createElement('li'));

console.log('Scripts runs first');
// Console output:
// "Script runs first"
// "Received 3 mutations"

This batching is what makes MutationObserver performant — even if a framework adds 100 elements in a render cycle, you get one callback with 100 records, not 100 individual callbacks.

Taking Action Immediately (microtask)

If you need to react before the next paint, use queueMicrotask:

const observer = new MutationObserver((mutations) => {
  queueMicrotask(() => {
    // Runs before the browser repaints
    recalculateLayout();
  });
});

Common Mistakes

1. Observing too broadly

// ❌ Fires on EVERY DOM change on the entire page
observer.observe(document.body, {
  childList: true,
  subtree: true,
  attributes: true,
});

// ✅ Scope to a specific container and filter attributes
observer.observe(document.getElementById('widget'), {
  childList: true,
  subtree: true,
  attributeFilter: ['data-state', 'class'],
});

2. Forgetting to disconnect

// ❌ Observer runs forever, leaking memory
function trackChanges() {
  const observer = new MutationObserver(callback);
  observer.observe(target, { childList: true });
  // Never disconnected!
}

// ✅ Return the observer or use a cleanup pattern
function trackChanges() {
  const observer = new MutationObserver(callback);
  observer.observe(target, { childList: true });
  return () => observer.disconnect();
}

const cleanup = trackChanges();
// Later: cleanup();

3. Modifying the DOM inside the callback

// ❌ Infinite loop — your change triggers another mutation
const observer = new MutationObserver(() => {
  target.classList.add('processed'); // This triggers the observer again!
});
observer.observe(target, { attributes: true });

// ✅ Disconnect before modifying, or use a flag
let isUpdating = false;
const observer = new MutationObserver(() => {
  if (isUpdating) return;
  isUpdating = true;
  target.classList.add('processed');
  isUpdating = false;
});

4. Not checking node types

// ❌ Text nodes don't have querySelector or matches
mutation.addedNodes.forEach(node => {
  node.querySelector('.foo'); // TypeError if node is a text node!
});

// ✅ Check nodeType first
mutation.addedNodes.forEach(node => {
  if (node.nodeType !== Node.ELEMENT_NODE) return;
  // Now safe to use element methods
  const child = node.querySelector('.foo');
});

Performance Characteristics

MutationObserver is highly optimised by browser engines. Unlike setInterval polling:

  • No JavaScript runs between mutations — the browser tracks changes natively
  • Callbacks are batched — multiple changes in a frame produce one callback
  • Observation is scoped — only the target subtree is watched

However, there are costs to be aware of:

ScenarioCost
subtree: true on <body> with attributes: trueHigh — fires on every attribute change page-wide
childList: true on a specific containerLow — only fires when children change
attributeFilter: ['data-state']Lowest — ignores all attributes except the one you care about
1000+ elements with individual observersHigh overhead — use one observer with subtree instead

Rule: One observer per concern, scoped as tightly as possible. If you need to watch multiple elements, observe their common ancestor with subtree: true instead of creating N observers.

Browser Support

MutationObserver is supported in all modern browsers (98%+ globally). It's been stable since 2013. No polyfill needed.

Quick Reference

What you needObserver config
Wait for element to appear{ childList: true, subtree: true }
Track class changes{ attributes: true, attributeFilter: ['class'] }
Watch text edits{ characterData: true, subtree: true }
Track added scripts/iframes{ childList: true, subtree: true } on <html>
Get old valuesAdd attributeOldValue: true or characterDataOldValue: true
Stop watchingobserver.disconnect() (all) or observer.unobserve(target) (one)

Key Takeaways

  • MutationObserver replaces polling — the browser tracks DOM changes natively and tells you when they happen
  • Scope your observations tightly — use attributeFilter, watch the smallest target possible, and avoid subtree: true on <body> unless you specifically need it
  • Mutations are batched asynchronously — you won't get one callback per change; you'll get one callback with an array of changes
  • Always disconnect when done — observers hold references to DOM elements and will leak memory if left running
  • Never modify the observed DOM inside the callback without a guard — it creates infinite mutation loops
  • Check nodeType before calling element methods on added nodes — text nodes and comment nodes don't have querySelector

MutationObserver is the difference between fighting the DOM and working with it. Once you start using it, you'll wonder how you ever tracked DOM changes without it.

Donate