Tracking DOM Changes with JS Mutation Observers

A complete tutorial on tracking DOM changes with JavaScript Mutation Observers. Covers building a DOM diff tracker, attribute change history, content-editable undo/redo, form mutation detection, third-party script monitoring, ad-blocker detection, element removal guards, live search filtering, and building a reactive DOM binding system.

JavaScriptintermediate
15 min read

MutationObserver is powerful for monitoring the DOM, but real-world tracking requires structured recording, diffing, and reaction patterns. This guide builds on the core API to create practical tracking systems for content changes, undo/redo, security monitoring, and reactive bindings.

For the core MutationObserver API, see JavaScript Mutation Observer: complete tutorial.

Attribute Change History

Track every attribute change on an element with a full history log:

javascriptjavascript
class AttributeHistory {
  constructor(element, attributes = []) {
    this.element = element;
    this.history = new Map();
    this.maxEntries = 100;
 
    this.observer = new MutationObserver((mutations) => {
      for (const mutation of mutations) {
        if (mutation.type !== "attributes") continue;
 
        const name = mutation.attributeName;
        if (!this.history.has(name)) {
          this.history.set(name, []);
        }
 
        const record = {
          oldValue: mutation.oldValue,
          newValue: element.getAttribute(name),
          timestamp: Date.now(),
        };
 
        const entries = this.history.get(name);
        entries.push(record);
        if (entries.length > this.maxEntries) entries.shift();
      }
    });
 
    this.observer.observe(element, {
      attributes: true,
      attributeOldValue: true,
      attributeFilter: attributes.length ? attributes : undefined,
    });
  }
 
  getHistory(attribute) {
    return this.history.get(attribute) || [];
  }
 
  getLastChange(attribute) {
    const entries = this.getHistory(attribute);
    return entries.length ? entries[entries.length - 1] : null;
  }
 
  getChangedAttributes() {
    return [...this.history.keys()];
  }
 
  getChangeCount(attribute) {
    return this.getHistory(attribute).length;
  }
 
  clear() {
    this.history.clear();
  }
 
  destroy() {
    this.observer.disconnect();
    this.history.clear();
  }
}
 
// Usage
const tracker = new AttributeHistory(
  document.getElementById("panel"),
  ["class", "data-state", "aria-expanded"]
);
 
// After some changes...
console.log(tracker.getHistory("class"));
// [{ oldValue: "panel", newValue: "panel active", timestamp: ... }]

Content-Editable Undo/Redo

javascriptjavascript
class UndoManager {
  constructor(element) {
    this.element = element;
    this.undoStack = [];
    this.redoStack = [];
    this.maxHistory = 50;
    this.isUndoing = false;
 
    // Capture initial state
    this.undoStack.push(element.innerHTML);
 
    this.observer = new MutationObserver(() => {
      if (this.isUndoing) return;
 
      const current = element.innerHTML;
      const last = this.undoStack[this.undoStack.length - 1];
 
      if (current !== last) {
        this.undoStack.push(current);
        this.redoStack = []; // Clear redo on new changes
 
        if (this.undoStack.length > this.maxHistory) {
          this.undoStack.shift();
        }
      }
    });
 
    this.observer.observe(element, {
      childList: true,
      characterData: true,
      attributes: true,
      subtree: true,
    });
 
    // Keyboard shortcuts
    element.addEventListener("keydown", (event) => {
      if (event.ctrlKey && event.key === "z" && !event.shiftKey) {
        event.preventDefault();
        this.undo();
      }
      if (event.ctrlKey && (event.key === "y" || (event.key === "z" && event.shiftKey))) {
        event.preventDefault();
        this.redo();
      }
    });
  }
 
  undo() {
    if (this.undoStack.length <= 1) return false;
 
    this.isUndoing = true;
    const current = this.undoStack.pop();
    this.redoStack.push(current);
    this.element.innerHTML = this.undoStack[this.undoStack.length - 1];
    this.isUndoing = false;
 
    return true;
  }
 
  redo() {
    if (this.redoStack.length === 0) return false;
 
    this.isUndoing = true;
    const state = this.redoStack.pop();
    this.undoStack.push(state);
    this.element.innerHTML = state;
    this.isUndoing = false;
 
    return true;
  }
 
  canUndo() {
    return this.undoStack.length > 1;
  }
 
  canRedo() {
    return this.redoStack.length > 0;
  }
 
  destroy() {
    this.observer.disconnect();
  }
}
 
// Usage
const editor = document.getElementById("rich-editor");
editor.contentEditable = true;
const undo = new UndoManager(editor);

Form Mutation Detector

Detect when forms are dynamically modified by scripts or extensions:

javascriptjavascript
class FormMutationDetector {
  constructor(form) {
    this.form = form;
    this.listeners = new Set();
    this.baseline = this.captureBaseline();
 
    this.observer = new MutationObserver((mutations) => {
      const changes = this.analyzeMutations(mutations);
      if (changes.length > 0) {
        this.notify(changes);
      }
    });
 
    this.observer.observe(form, {
      childList: true,
      attributes: true,
      subtree: true,
      attributeFilter: [
        "value", "checked", "disabled", "readonly",
        "required", "name", "type", "hidden",
      ],
      attributeOldValue: true,
    });
  }
 
  captureBaseline() {
    const inputs = this.form.querySelectorAll("input, select, textarea");
    const state = new Map();
 
    inputs.forEach((input) => {
      state.set(input.name || input.id, {
        type: input.type,
        disabled: input.disabled,
        required: input.required,
        hidden: input.hidden,
      });
    });
 
    return state;
  }
 
  analyzeMutations(mutations) {
    const changes = [];
 
    for (const mutation of mutations) {
      if (mutation.type === "childList") {
        mutation.addedNodes.forEach((node) => {
          if (node.nodeType === Node.ELEMENT_NODE) {
            if (node.matches("input, select, textarea, button")) {
              changes.push({
                type: "field-added",
                element: node.tagName.toLowerCase(),
                name: node.name || node.id,
              });
            }
          }
        });
 
        mutation.removedNodes.forEach((node) => {
          if (node.nodeType === Node.ELEMENT_NODE) {
            if (node.matches("input, select, textarea, button")) {
              changes.push({
                type: "field-removed",
                element: node.tagName.toLowerCase(),
                name: node.name || node.id,
              });
            }
          }
        });
      }
 
      if (mutation.type === "attributes") {
        changes.push({
          type: "attribute-changed",
          target: mutation.target.tagName.toLowerCase(),
          name: mutation.target.name || mutation.target.id,
          attribute: mutation.attributeName,
          oldValue: mutation.oldValue,
          newValue: mutation.target.getAttribute(mutation.attributeName),
        });
      }
    }
 
    return changes;
  }
 
  onChange(callback) {
    this.listeners.add(callback);
    return () => this.listeners.delete(callback);
  }
 
  notify(changes) {
    for (const listener of this.listeners) {
      listener(changes);
    }
  }
 
  destroy() {
    this.observer.disconnect();
  }
}
 
// Usage
const form = document.getElementById("checkout-form");
const detector = new FormMutationDetector(form);
 
detector.onChange((changes) => {
  changes.forEach((change) => {
    console.warn("Form mutation detected:", change);
  });
});

Element Removal Guard

Prevent critical elements from being removed (e.g., by ad blockers or scripts):

javascriptjavascript
class RemovalGuard {
  constructor(selector) {
    this.selector = selector;
    this.snapshots = new Map();
    this.observer = null;
  }
 
  start() {
    // Snapshot protected elements
    document.querySelectorAll(this.selector).forEach((el) => {
      this.snapshots.set(el, {
        html: el.outerHTML,
        parent: el.parentNode,
        nextSibling: el.nextSibling,
      });
    });
 
    this.observer = new MutationObserver((mutations) => {
      for (const mutation of mutations) {
        mutation.removedNodes.forEach((node) => {
          if (node.nodeType !== Node.ELEMENT_NODE) return;
 
          if (this.snapshots.has(node)) {
            this.restoreElement(node);
          }
 
          // Check nested protected elements
          if (node.querySelectorAll) {
            node.querySelectorAll(this.selector).forEach((child) => {
              if (this.snapshots.has(child)) {
                this.restoreElement(child);
              }
            });
          }
        });
      }
    });
 
    this.observer.observe(document.body, {
      childList: true,
      subtree: true,
    });
  }
 
  restoreElement(node) {
    const snapshot = this.snapshots.get(node);
    if (!snapshot || !snapshot.parent) return;
 
    console.warn("Protected element removed, restoring:", node);
 
    // Temporarily pause observer to avoid infinite loop
    this.observer.disconnect();
 
    try {
      if (snapshot.nextSibling && snapshot.parent.contains(snapshot.nextSibling)) {
        snapshot.parent.insertBefore(node, snapshot.nextSibling);
      } else {
        snapshot.parent.appendChild(node);
      }
    } catch (error) {
      console.error("Failed to restore element:", error);
    }
 
    this.observer.observe(document.body, {
      childList: true,
      subtree: true,
    });
  }
 
  stop() {
    if (this.observer) {
      this.observer.disconnect();
    }
    this.snapshots.clear();
  }
}
 
// Protect critical UI elements
const guard = new RemovalGuard("[data-protected]");
guard.start();

Reactive DOM Bindings

javascriptjavascript
class ReactiveBinder {
  constructor() {
    this.bindings = new Map();
    this.observer = null;
  }
 
  bind(sourceSelector, targetSelector, transform = (v) => v) {
    const key = `${sourceSelector}:${targetSelector}`;
    this.bindings.set(key, { sourceSelector, targetSelector, transform });
  }
 
  start() {
    this.observer = new MutationObserver((mutations) => {
      for (const mutation of mutations) {
        if (mutation.type === "attributes" || mutation.type === "characterData") {
          this.processBindings(mutation.target);
        }
 
        if (mutation.type === "childList") {
          this.processBindings(mutation.target);
        }
      }
    });
 
    this.observer.observe(document.body, {
      childList: true,
      attributes: true,
      characterData: true,
      subtree: true,
      attributeOldValue: true,
    });
 
    // Initial sync
    this.syncAll();
  }
 
  processBindings(changedNode) {
    for (const [, binding] of this.bindings) {
      const source = document.querySelector(binding.sourceSelector);
 
      if (source && (source === changedNode || source.contains(changedNode))) {
        const target = document.querySelector(binding.targetSelector);
        if (target) {
          const value = source.textContent || source.value || "";
          const transformed = binding.transform(value);
 
          if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") {
            target.value = transformed;
          } else {
            target.textContent = transformed;
          }
        }
      }
    }
  }
 
  syncAll() {
    for (const [, binding] of this.bindings) {
      const source = document.querySelector(binding.sourceSelector);
      const target = document.querySelector(binding.targetSelector);
 
      if (source && target) {
        const value = source.textContent || source.value || "";
        const transformed = binding.transform(value);
 
        if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") {
          target.value = transformed;
        } else {
          target.textContent = transformed;
        }
      }
    }
  }
 
  stop() {
    if (this.observer) this.observer.disconnect();
  }
}
 
// Usage
const binder = new ReactiveBinder();
 
binder.bind("#item-count", "#count-display");
binder.bind("#price", "#formatted-price", (v) => `$${parseFloat(v).toFixed(2)}`);
binder.start();

Performance Tips

TipWhy
Use attributeFilterReduces mutations to relevant attributes only
Set subtree: false when possibleLimits observation scope
Disconnect during batch updatesPrevents processing intermediate states
Use takeRecords() before disconnectProcess pending mutations
Debounce callback processingAvoids thrashing on rapid changes
Use WeakSet for processed nodesPrevents memory leaks and double processing
Rune AI

Rune AI

Key Insights

  • Attribute history with old values: Enable attributeOldValue: true to build full change logs showing what attributes were and what they became
  • Undo/redo with innerHTML snapshots: Pause the observer during undo/redo operations with an isUndoing flag to prevent recording the restoration as a new change
  • Form mutation detection for security: Watch for dynamically added/removed fields and attribute changes that could indicate script tampering or extension interference
  • Element removal guards with auto-restore: Detect when critical elements are removed and re-insert them, pausing the observer during restoration to avoid infinite loops
  • Reactive bindings without frameworks: Use MutationObserver to propagate changes from source elements to targets with optional transform functions
RunePowered by Rune AI

Frequently Asked Questions

Can MutationObserver detect style changes from CSS classes?

It detects when the `class` attribute changes (with `attributes: true`), but it does not detect which CSS properties actually changed as a result. To track specific computed styles, you would need `ResizeObserver` for size changes or periodic polling with `getComputedStyle` for other properties.

How do I observe shadow DOM content?

Call `observer.observe(shadowRoot, { ... })` on the shadow root directly. The main document observer with `subtree: true` does not penetrate shadow DOM boundaries. You need a separate observer for each shadow root.

Does MutationObserver work with virtual DOM libraries?

Yes, but virtual DOM libraries (React, Vue) batch their DOM updates. MutationObserver sees the final DOM changes, not the virtual diff. This is actually beneficial for tracking because you see only the real DOM mutations after reconciliation.

How do I track which script caused a DOM mutation?

MutationObserver does not provide call stack information. To identify the source, use `console.trace()` inside the callback combined with Chrome DevTools performance recording. For production monitoring, log mutation patterns and correlate with known script behaviors.

Can I use MutationObserver for accessibility auditing?

Yes. Observe `attributes: true` with `attributeFilter: ["role", "aria-label", "aria-hidden", "tabindex"]` to detect accessibility attribute changes. Combined with [JS Intersection Observer](/tutorials/programming-languages/javascript/js-intersection-observer-api-complete-tutorial) for visibility tracking, you can build a live accessibility monitor.

Conclusion

Tracking DOM changes with MutationObserver enables attribute history logging, undo/redo systems, form tampering detection, element removal guards, and reactive DOM bindings. Always scope observations as narrowly as possible, use attributeFilter, and disconnect during batch updates. For the core API reference, see JavaScript Mutation Observer: complete tutorial. For viewport-based tracking, see JS Intersection Observer API: complete tutorial.