The MutationObserver API provides the ability to monitor DOM changes. This article covers its usage and practical applications.

Basic Concepts

Creating an Observer

// Create observer
const observer = new MutationObserver((mutations, observer) => {
  mutations.forEach(mutation => {
    console.log('DOM change:', mutation.type);
  });
});

// Start observing
const targetNode = document.getElementById('target');
observer.observe(targetNode, {
  childList: true,
  attributes: true,
  subtree: true
});

// Stop observing
observer.disconnect();

// Get pending records
const pendingMutations = observer.takeRecords();

Configuration Options

const config = {
  // Watch child node changes
  childList: true,

  // Watch attribute changes
  attributes: true,

  // Watch character data changes (text nodes)
  characterData: true,

  // Watch all descendant nodes
  subtree: true,

  // Record old attribute values
  attributeOldValue: true,

  // Record old character data values
  characterDataOldValue: true,

  // Only watch specific attributes
  attributeFilter: ['class', 'style', 'data-id']
};

observer.observe(targetNode, config);

MutationRecord

const observer = new MutationObserver((mutations) => {
  mutations.forEach(mutation => {
    // Change type: 'attributes', 'characterData', 'childList'
    console.log(mutation.type);

    // Target node where change occurred
    console.log(mutation.target);

    // List of added nodes
    console.log(mutation.addedNodes);

    // List of removed nodes
    console.log(mutation.removedNodes);

    // Previous sibling node
    console.log(mutation.previousSibling);

    // Next sibling node
    console.log(mutation.nextSibling);

    // Changed attribute name
    console.log(mutation.attributeName);

    // Attribute namespace
    console.log(mutation.attributeNamespace);

    // Old value (requires oldValue option)
    console.log(mutation.oldValue);
  });
});

Practical Applications

Attribute Change Monitoring

class AttributeWatcher {
  constructor(element, callback) {
    this.element = element;
    this.callback = callback;

    this.observer = new MutationObserver((mutations) => {
      mutations.forEach(mutation => {
        if (mutation.type === 'attributes') {
          const attrName = mutation.attributeName;
          const oldValue = mutation.oldValue;
          const newValue = element.getAttribute(attrName);

          this.callback({
            attribute: attrName,
            oldValue,
            newValue,
            element: mutation.target
          });
        }
      });
    });

    this.observer.observe(element, {
      attributes: true,
      attributeOldValue: true
    });
  }

  watchOnly(attributes) {
    this.observer.disconnect();
    this.observer.observe(this.element, {
      attributes: true,
      attributeOldValue: true,
      attributeFilter: attributes
    });
  }

  disconnect() {
    this.observer.disconnect();
  }
}

// Usage
const watcher = new AttributeWatcher(
  document.getElementById('myElement'),
  (change) => {
    console.log('Attribute changed:', change.attribute);
    console.log('From', change.oldValue, 'to', change.newValue);
  }
);

// Watch specific attributes only
watcher.watchOnly(['class', 'data-state']);

Child Node Change Monitoring

class ChildrenWatcher {
  constructor(container, options = {}) {
    this.container = container;
    this.options = {
      onAdd: () => {},
      onRemove: () => {},
      deep: false,
      ...options
    };

    this.observer = new MutationObserver((mutations) => {
      mutations.forEach(mutation => {
        if (mutation.type === 'childList') {
          mutation.addedNodes.forEach(node => {
            if (node.nodeType === Node.ELEMENT_NODE) {
              this.options.onAdd(node, mutation.target);
            }
          });

          mutation.removedNodes.forEach(node => {
            if (node.nodeType === Node.ELEMENT_NODE) {
              this.options.onRemove(node, mutation.target);
            }
          });
        }
      });
    });

    this.observer.observe(container, {
      childList: true,
      subtree: this.options.deep
    });
  }

  disconnect() {
    this.observer.disconnect();
  }
}

// Usage
const watcher = new ChildrenWatcher(
  document.getElementById('container'),
  {
    onAdd: (node, parent) => {
      console.log('Node added:', node.tagName);
      node.classList.add('fade-in');
    },
    onRemove: (node, parent) => {
      console.log('Node removed:', node.tagName);
    },
    deep: true
  }
);

Text Content Monitoring

class TextContentWatcher {
  constructor(element, callback) {
    this.element = element;
    this.callback = callback;

    this.observer = new MutationObserver((mutations) => {
      mutations.forEach(mutation => {
        if (mutation.type === 'characterData') {
          this.callback({
            oldValue: mutation.oldValue,
            newValue: mutation.target.textContent,
            node: mutation.target
          });
        }
      });
    });

    this.observer.observe(element, {
      characterData: true,
      characterDataOldValue: true,
      subtree: true
    });
  }

  disconnect() {
    this.observer.disconnect();
  }
}

// Usage
const textWatcher = new TextContentWatcher(
  document.getElementById('editor'),
  (change) => {
    console.log('Text changed:', change.oldValue, '->', change.newValue);
  }
);

Auto-Save Form

class AutoSaveForm {
  constructor(form, options = {}) {
    this.form = form;
    this.options = {
      saveKey: 'formAutoSave',
      debounceTime: 1000,
      ...options
    };

    this.saveTimeout = null;
    this.setupObserver();
    this.loadSavedData();
  }

  setupObserver() {
    this.observer = new MutationObserver((mutations) => {
      this.scheduleSave();
    });

    // Monitor form internal changes
    this.observer.observe(this.form, {
      attributes: true,
      childList: true,
      subtree: true,
      characterData: true
    });

    // Also listen for input events
    this.form.addEventListener('input', () => {
      this.scheduleSave();
    });
  }

  scheduleSave() {
    if (this.saveTimeout) {
      clearTimeout(this.saveTimeout);
    }

    this.saveTimeout = setTimeout(() => {
      this.save();
    }, this.options.debounceTime);
  }

  save() {
    const formData = new FormData(this.form);
    const data = Object.fromEntries(formData.entries());

    localStorage.setItem(this.options.saveKey, JSON.stringify({
      data,
      timestamp: Date.now()
    }));

    console.log('Form auto-saved');
  }

  loadSavedData() {
    const saved = localStorage.getItem(this.options.saveKey);

    if (saved) {
      try {
        const { data } = JSON.parse(saved);

        Object.entries(data).forEach(([name, value]) => {
          const input = this.form.elements[name];
          if (input) {
            input.value = value;
          }
        });
      } catch (e) {
        console.error('Failed to load saved data:', e);
      }
    }
  }

  clearSaved() {
    localStorage.removeItem(this.options.saveKey);
  }

  disconnect() {
    this.observer.disconnect();
    if (this.saveTimeout) {
      clearTimeout(this.saveTimeout);
    }
  }
}

// Usage
const autoSave = new AutoSaveForm(
  document.getElementById('myForm'),
  {
    saveKey: 'myFormDraft',
    debounceTime: 500
  }
);

DOM Change History

class DOMHistory {
  constructor(target, options = {}) {
    this.target = target;
    this.options = {
      maxHistory: 100,
      ...options
    };

    this.history = [];
    this.setupObserver();
  }

  setupObserver() {
    this.observer = new MutationObserver((mutations) => {
      const record = {
        timestamp: Date.now(),
        mutations: mutations.map(m => this.serializeMutation(m))
      };

      this.history.push(record);

      if (this.history.length > this.options.maxHistory) {
        this.history.shift();
      }
    });

    this.observer.observe(this.target, {
      attributes: true,
      attributeOldValue: true,
      childList: true,
      characterData: true,
      characterDataOldValue: true,
      subtree: true
    });
  }

  serializeMutation(mutation) {
    return {
      type: mutation.type,
      target: this.getNodePath(mutation.target),
      attributeName: mutation.attributeName,
      oldValue: mutation.oldValue,
      addedNodes: Array.from(mutation.addedNodes).length,
      removedNodes: Array.from(mutation.removedNodes).length
    };
  }

  getNodePath(node) {
    const path = [];
    let current = node;

    while (current && current !== this.target) {
      const parent = current.parentNode;
      if (parent) {
        const index = Array.from(parent.children).indexOf(current);
        path.unshift(current.tagName + '[' + index + ']');
      }
      current = parent;
    }

    return path.join(' > ');
  }

  getHistory() {
    return [...this.history];
  }

  clearHistory() {
    this.history = [];
  }

  disconnect() {
    this.observer.disconnect();
  }
}

// Usage
const domHistory = new DOMHistory(
  document.getElementById('editor'),
  { maxHistory: 50 }
);

// Get history
console.log(domHistory.getHistory());

Dynamic Content Load Detection

class DynamicContentDetector {
  constructor(container, options = {}) {
    this.container = container;
    this.options = {
      onImageLoad: () => {},
      onScriptLoad: () => {},
      onContentReady: () => {},
      ...options
    };

    this.pendingImages = new Set();
    this.setupObserver();
  }

  setupObserver() {
    this.observer = new MutationObserver((mutations) => {
      mutations.forEach(mutation => {
        mutation.addedNodes.forEach(node => {
          if (node.nodeType === Node.ELEMENT_NODE) {
            this.processElement(node);
          }
        });
      });
    });

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

  processElement(element) {
    // Process images
    const images = element.matches('img')
      ? [element]
      : element.querySelectorAll('img');

    images.forEach(img => {
      if (!img.complete) {
        this.pendingImages.add(img);

        img.addEventListener('load', () => {
          this.pendingImages.delete(img);
          this.options.onImageLoad(img);
          this.checkAllLoaded();
        });

        img.addEventListener('error', () => {
          this.pendingImages.delete(img);
          this.checkAllLoaded();
        });
      }
    });

    // Process scripts
    const scripts = element.matches('script')
      ? [element]
      : element.querySelectorAll('script');

    scripts.forEach(script => {
      if (script.src) {
        script.addEventListener('load', () => {
          this.options.onScriptLoad(script);
        });
      }
    });

    // Trigger content ready callback
    this.options.onContentReady(element);
  }

  checkAllLoaded() {
    if (this.pendingImages.size === 0) {
      console.log('All images loaded');
    }
  }

  disconnect() {
    this.observer.disconnect();
  }
}

// Usage
const detector = new DynamicContentDetector(
  document.getElementById('content'),
  {
    onImageLoad: (img) => {
      console.log('Image loaded:', img.src);
    },
    onContentReady: (element) => {
      console.log('New content ready:', element.tagName);
    }
  }
);

Third-Party Library Detection

class ThirdPartyDetector {
  constructor(callback) {
    this.callback = callback;
    this.detected = new Set();

    this.setupObserver();
    this.checkExisting();
  }

  setupObserver() {
    this.observer = new MutationObserver((mutations) => {
      mutations.forEach(mutation => {
        mutation.addedNodes.forEach(node => {
          if (node.nodeType === Node.ELEMENT_NODE) {
            this.detectLibraries(node);
          }
        });
      });
    });

    this.observer.observe(document.head, {
      childList: true
    });

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

  checkExisting() {
    document.querySelectorAll('script[src]').forEach(script => {
      this.detectFromScript(script);
    });
  }

  detectLibraries(element) {
    if (element.matches('script[src]')) {
      this.detectFromScript(element);
    }

    element.querySelectorAll('script[src]').forEach(script => {
      this.detectFromScript(script);
    });
  }

  detectFromScript(script) {
    const src = script.src.toLowerCase();
    const libraries = {
      jquery: /jquery/,
      react: /react/,
      vue: /vue/,
      angular: /angular/,
      lodash: /lodash/,
      analytics: /google-analytics|gtag/
    };

    Object.entries(libraries).forEach(([name, pattern]) => {
      if (pattern.test(src) && !this.detected.has(name)) {
        this.detected.add(name);
        this.callback(name, script.src);
      }
    });
  }

  getDetectedLibraries() {
    return [...this.detected];
  }

  disconnect() {
    this.observer.disconnect();
  }
}

// Usage
const detector = new ThirdPartyDetector((library, src) => {
  console.log('Detected library:', library, 'from', src);
});

Performance Optimization

Batch Processing Changes

class BatchedMutationObserver {
  constructor(callback, options = {}) {
    this.callback = callback;
    this.options = {
      batchTime: 100,
      ...options
    };

    this.pendingMutations = [];
    this.batchTimeout = null;

    this.observer = new MutationObserver((mutations) => {
      this.pendingMutations.push(...mutations);
      this.scheduleBatch();
    });
  }

  scheduleBatch() {
    if (this.batchTimeout) return;

    this.batchTimeout = setTimeout(() => {
      const mutations = this.pendingMutations;
      this.pendingMutations = [];
      this.batchTimeout = null;

      this.callback(mutations);
    }, this.options.batchTime);
  }

  observe(target, config) {
    this.observer.observe(target, config);
  }

  disconnect() {
    this.observer.disconnect();
    if (this.batchTimeout) {
      clearTimeout(this.batchTimeout);
    }
  }
}

// Usage
const batchedObserver = new BatchedMutationObserver(
  (mutations) => {
    console.log('Batch processing', mutations.length, 'changes');
  },
  { batchTime: 200 }
);

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

Filtering Irrelevant Changes

class FilteredMutationObserver {
  constructor(callback, filter) {
    this.callback = callback;
    this.filter = filter;

    this.observer = new MutationObserver((mutations) => {
      const filtered = mutations.filter(m => this.filter(m));

      if (filtered.length > 0) {
        this.callback(filtered);
      }
    });
  }

  observe(target, config) {
    this.observer.observe(target, config);
  }

  disconnect() {
    this.observer.disconnect();
  }
}

// Usage: Only process changes to elements with specific class
const filteredObserver = new FilteredMutationObserver(
  (mutations) => {
    mutations.forEach(m => {
      console.log('Matching change:', m);
    });
  },
  (mutation) => {
    const target = mutation.target;
    return target.classList && target.classList.contains('monitored');
  }
);

Best Practices Summary

MutationObserver Best Practices:
┌─────────────────────────────────────────────────────┐
│                                                     │
│   Performance                                       │
│   ├── Limit observation scope (avoid subtree)      │
│   ├── Use attributeFilter to filter attributes     │
│   ├── Batch process changes                        │
│   └── Disconnect unneeded observers promptly       │
│                                                     │
│   Use Cases                                         │
│   ├── Dynamic content monitoring                   │
│   ├── Form auto-save                               │
│   ├── Third-party library detection                │
│   └── DOM change history                           │
│                                                     │
│   Considerations                                    │
│   ├── Callbacks run in microtask queue             │
│   ├── Avoid modifying DOM in callbacks             │
│   ├── Watch for memory leaks                       │
│   └── Handle old value recording properly          │
│                                                     │
└─────────────────────────────────────────────────────┘
ConfigRecommendationNotes
subtreeUse cautiouslyHigh overhead
attributeFilterPrefer usingReduces callbacks
oldValueUse as neededMemory overhead

Master the MutationObserver API for precise DOM change monitoring.