Mutation Observer — Complete Guide
In this tutorial, you will learn about Mutation Observer. We cover key concepts, practical examples, and best practices to help you master this topic.
Mutation Observer watches for DOM changes including child node additions, removals, attribute modifications, and text content changes with detailed mutation records.
What You'll Learn
- How to create a Mutation Observer and configure which changes to watch
- How to read mutation records for added, removed, and modified nodes
- How to watch for attribute changes with attributeFilter
- How to watch for text content changes with characterData
- Performance considerations and when to use Mutation Observer
Why It Matters
Sometimes you need to react to DOM changes made by other scripts, browser extensions, or user interactions. Mutation Observer provides a standardized, performant way to detect these changes without polling or dirty checking.
Real-World Use
- A browser extension highlights newly added content on a page
- A CMS preview panel watches for changes in the editor
- A chat application auto-scrolls when new messages appear
- A DevTools-like inspector highlights modified elements
flowchart LR A[Create Observer] --> B[new MutationObserver] B --> C[configure observe options] C --> D[target.observe element] D --> E[DOM changes occur] E --> F[Mutation Records Created] F --> G[Callback Fires] G --> H[Process changes]
Basic Mutation Observation
The simplest usage watches for child list changes on an element.
const target = document.querySelector('.watch-area');
const log = document.querySelector('.mutation-log');
// Create the observer
const observer = new MutationObserver(function(mutations) {
mutations.forEach(mutation => {
console.log('Mutation type:', mutation.type);
console.log('Target:', mutation.target);
if (mutation.type === 'childList') {
console.log('Added nodes:', mutation.addedNodes.length);
console.log('Removed nodes:', mutation.removedNodes.length);
mutation.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
log.textContent = `Element added: ${node.tagName}`;
}
});
mutation.removedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
log.textContent = `Element removed: ${node.tagName}`;
}
});
}
});
});
// Configuration
observer.observe(target, {
childList: true, // Watch for child node additions/removals
subtree: true, // Watch all descendants, not just direct children
attributes: false, // Do not watch attribute changes
characterData: false // Do not watch text changes
});
// Later: observer.disconnect();
Expected output: Adding or removing children inside the watched area logs the mutation type and details. The observer captures all childList mutations in the subtree.
Watching Attribute Changes
Configure the observer to detect attribute modifications.
const watchedElement = document.querySelector('.watched-element');
const attributeObserver = new MutationObserver(function(mutations) {
mutations.forEach(mutation => {
if (mutation.type === 'attributes') {
const attrName = mutation.attributeName;
const oldValue = mutation.oldValue;
const newValue = watchedElement.getAttribute(attrName);
console.log(`Attribute changed: ${attrName}`);
console.log(` Old value: ${oldValue}`);
console.log(` New value: ${newValue}`);
// React to specific attributes
if (attrName === 'class') {
updateClassStyles(watchedElement);
}
if (attrName === 'style') {
updateComputedStyles(watchedElement);
}
if (attrName === 'data-state') {
handleStateChange(watchedElement, newValue);
}
}
});
});
attributeObserver.observe(watchedElement, {
attributes: true,
attributeOldValue: true, // Record previous value
attributeFilter: ['class', 'style', 'data-state', 'hidden']
// Only watch these attributes; watch all if absent
});
function updateClassStyles(el) {
console.log('Class list updated:', el.className);
}
Expected output: Changing the class, style, data-state, or hidden attribute on the watched element logs the attribute name and old/new values. Other attribute changes are ignored due to attributeFilter.
Watching Text Changes
Detect when text content of a node changes.
const textTarget = document.querySelector('.editable-text');
const textObserver = new MutationObserver(function(mutations) {
mutations.forEach(mutation => {
if (mutation.type === 'characterData') {
console.log('Text changed');
console.log(' New text:', mutation.target.nodeValue);
console.log(' Old text:', mutation.oldValue);
}
// Also catch childList changes that might replace text nodes
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(node => {
if (node.nodeType === Node.TEXT_NODE) {
console.log('Text node added:', node.nodeValue);
}
});
}
});
});
textObserver.observe(textTarget, {
characterData: true,
characterDataOldValue: true,
subtree: true, // Needed if text is in child elements
childList: true // Needed for text node replacements
});
// Note: characterData observes #text nodes directly
// For contenteditable elements, use subtree: true
// because the text is inside child nodes
Expected output: Editing text inside the watched element logs each character change. The old value shows the previous text, and the new value shows the current text.
Performance: Batching and Disconnecting
Mutation Observer batches changes and supports throttling.
const largeList = document.querySelector('#large-list');
// Performance: throttle callback with requestAnimationFrame
let pendingMutations = [];
let rafId = null;
const perfObserver = new MutationObserver(function(mutations) {
// Accumulate mutations
pendingMutations = pendingMutations.concat(mutations);
// Schedule batch processing
if (!rafId) {
rafId = requestAnimationFrame(function() {
processBatch(pendingMutations);
pendingMutations = [];
rafId = null;
});
}
});
function processBatch(mutations) {
console.log(`Processing ${mutations.length} mutations in batch`);
const addedCount = mutations.reduce((sum, m) => {
return sum + (m.type === 'childList' ? m.addedNodes.length : 0);
}, 0);
const changedAttrs = new Set();
mutations.forEach(m => {
if (m.type === 'attributes') {
changedAttrs.add(`${m.target.tagName}:${m.attributeName}`);
}
});
console.log(` Total added nodes: ${addedCount}`);
console.log(` Unique attribute changes: ${changedAttrs.size}`);
}
perfObserver.observe(largeList, {
childList: true,
subtree: true,
attributes: true
});
// Always disconnect when no longer needed
// perfObserver.disconnect();
Expected output: DOM changes are batched into animation frame intervals. Instead of processing each mutation individually, they are accumulated and processed together for better performance.
Detecting Element Removal
Use Mutation Observer to detect when a specific element is removed from the DOM.
const watchContainer = document.querySelector('#dynamic-content');
const targetElement = document.querySelector('#important-widget');
const removalObserver = new MutationObserver(function(mutations) {
for (const mutation of mutations) {
// Check if our target is among the removed nodes
for (const removedNode of mutation.removedNodes) {
if (removedNode === targetElement ||
removedNode.contains(targetElement)) {
console.log('Target element was removed!');
cleanupWidget();
// Re-add or notify
restoreWidget();
}
}
}
});
removalObserver.observe(watchContainer, {
childList: true,
subtree: true
});
function cleanupWidget() {
console.log('Cleaning up widget resources');
// Remove event listeners, cancel timers, etc.
}
function restoreWidget() {
console.log('Restoring widget');
const newWidget = document.createElement('div');
newWidget.id = 'important-widget';
newWidget.textContent = 'Restored Widget';
watchContainer.appendChild(newWidget);
}
// Note: this pattern is useful for ensuring
// critical UI components are always present
Expected output: If the watched element is removed from the DOM (by any script), the observer detects the removal, cleans up, and restores the element.
Common Mistakes
- Not setting subtree: true when watching descendants — If subtree is false, the observer only watches direct children of the target. Changes in nested elements are missed.
- Forgetting to disconnect observers — MutationObserver keeps a reference to the callback and target. Not disconnecting causes memory leaks, especially in SPAs.
- Making DOM changes inside the callback — The observer callback is triggered by DOM changes. Making further changes inside the callback causes infinite loops. Use a flag or disconnect temporarily.
- Assuming characterData watches element textContent — characterData observes #text nodes directly. For contenteditable or elements with text children, use subtree: true and characterData: true.
- Processing mutations synchronously for large batches — Mutation records can accumulate rapidly. Batch processing with requestAnimationFrame prevents main thread blocking.
Practice Questions
- What configuration options does MutationObserver.observe accept? childList, attributes, characterData, subtree, attributeOldValue, characterDataOldValue, attributeFilter.
- How do you avoid infinite loops in a MutationObserver callback? Set a flag before making changes and check it in the callback, or temporarily disconnect the observer.
- What is the performance advantage of MutationObserver over DOMSubtreeModified? MutationObserver batches changes and uses async callbacks. DOMSubtreeModified was synchronous and fired for every change, causing severe performance issues.
- Challenge: Build a change tracker that highlights modified elements with a yellow flash. When any attribute or content changes on a watched element, briefly add a 'changed' class, log the change, and remove the class after 2 seconds.
FAQ
Mini Project
Build a DOM inspector tool. An overlay panel shows real-time information about DOM changes on the page. Use MutationObserver to detect: 1) When new elements are added (log tagName, class, and position), 2) When elements are removed, 3) When classes or styles change. Display the last 20 mutations in a scrollable log panel. Use batching to avoid flooding the display.
What's Next
Continue with Lesson 22: Resize Observer to learn how to detect when elements resize for responsive components and adaptive layouts.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro