# JavaScript MutationObserver API Complete Guide
Master DOM change monitoring: attribute observation, child node changes, performance optimization, and practical applications
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 │
│ │
└─────────────────────────────────────────────────────┘
| Config | Recommendation | Notes |
|---|---|---|
| subtree | Use cautiously | High overhead |
| attributeFilter | Prefer using | Reduces callbacks |
| oldValue | Use as needed | Memory overhead |
Master the MutationObserver API for precise DOM change monitoring.