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.
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:
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
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:
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):
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
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
| Tip | Why |
|---|---|
Use attributeFilter | Reduces mutations to relevant attributes only |
Set subtree: false when possible | Limits observation scope |
| Disconnect during batch updates | Prevents processing intermediate states |
Use takeRecords() before disconnect | Process pending mutations |
| Debounce callback processing | Avoids thrashing on rapid changes |
Use WeakSet for processed nodes | Prevents memory leaks and double processing |
Rune AI
Key Insights
- Attribute history with old values: Enable
attributeOldValue: trueto 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
isUndoingflag 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
Frequently Asked Questions
Can MutationObserver detect style changes from CSS classes?
How do I observe shadow DOM content?
Does MutationObserver work with virtual DOM libraries?
How do I track which script caused a DOM mutation?
Can I use MutationObserver for accessibility auditing?
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.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.