The Mutation Observer API
The Mutation Observer API provides a way to react to changes in the DOM. It is designed to replace the older Mutation Events API, which was deprecated due to performance issues. Mutation Observers are asynchronous and batched, making them efficient for monitoring DOM changes in modern web applications.
Before Mutation Observer, developers used MutationEvent listeners that fired synchronously for every single change, causing significant performance degradation in applications with frequent DOM updates.
How Mutation Observer Works
Instead of listening for individual mutation events, you create a Mutation Observer that receives a batched callback when mutations occur:
const callback = (mutationsList, observer) => {
mutationsList.forEach(mutation => {
console.log(mutation.type, mutation.target);
});
};
const observer = new MutationObserver(callback);
The observer does nothing until you tell it what to watch:
const targetNode = document.querySelector('#app');
const config = {
attributes: true,
childList: true,
subtree: true
};
observer.observe(targetNode, config);
Configuration Options
The observe method accepts a configuration object that specifies which types of mutations to watch:
childList
Set to true to observe direct children added or removed:
const observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
mutation.addedNodes.forEach(node => {
console.log('Added:', node.textContent);
});
mutation.removedNodes.forEach(node => {
console.log('Removed:', node.textContent);
});
});
});
observer.observe(target, { childList: true });
subtree
Extend observation to all descendants by setting subtree to true:
observer.observe(target, {
childList: true,
subtree: true
});
attributes
Monitor attribute changes on the target element:
observer.observe(target, {
attributes: true,
attributeFilter: ['class', 'data-value']
});
Watch for attribute old value with attributeOldValue:
observer.observe(target, {
attributes: true,
attributeOldValue: true
});
characterData
Track text content changes in text nodes:
observer.observe(target, {
characterData: true,
characterDataOldValue: true
});
Understanding Mutation Records
Each mutation in the callback provides detailed information through a MutationRecord object:
const observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
console.log('Type:', mutation.type);
console.log('Target:', mutation.target);
console.log('Added nodes:', mutation.addedNodes.length);
console.log('Removed nodes:', mutation.removedNodes.length);
console.log('Attribute name:', mutation.attributeName);
console.log('Old value:', mutation.oldValue);
});
});
The properties available depend on the mutation type. The type property returns ‘attributes’, ‘childList’, or ‘characterData’. The target is the node that was affected.
Practical Example: Form Validation Feedback
Mutation Observer works well for dynamically showing validation messages when form fields change:
const formFields = document.querySelectorAll('input[data-validate]');
const validateObserver = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
if (mutation.type === 'attributes' && mutation.attributeName === 'value') {
validateField(mutation.target);
}
});
});
formFields.forEach(field => {
validateObserver.observe(field, {
attributes: true,
attributeFilter: ['value'],
attributeOldValue: true
});
});
function validateField(field) {
const isValid = field.checkValidity();
field.classList.toggle('valid', isValid);
field.classList.toggle('invalid', !isValid);
}
Detecting Class Changes
You can use Mutation Observer to react when CSS classes change on an element:
const classChangeObserver = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
const element = mutation.target;
const wasActive = mutation.oldValue?.includes('active');
const isActive = element.classList.contains('active');
if (!wasActive && isActive) {
console.log('Element activated');
initializeComponent(element);
} else if (wasActive && !isActive) {
console.log('Element deactivated');
cleanupComponent(element);
}
}
});
});
classChangeObserver.observe(element, {
attributes: true,
attributeOldValue: true
});
Implementing a Content Editable Editor
Mutation Observer is essential for building editors with contentEditable or when using custom rich text editors:
class RichEditor {
constructor(element) {
this.element = element;
this.setupObserver();
}
setupObserver() {
this.observer = new MutationObserver((mutations) => {
mutations.forEach(mutation => {
if (mutation.type === 'childList') {
this.handleContentChange();
}
});
});
this.observer.observe(this.element, {
childList: true,
subtree: true,
characterData: true,
characterDataOldValue: true
});
}
handleContentChange() {
const text = this.element.innerText;
const wordCount = text.trim().split(/\s+/).filter(w => w).length;
this.updateWordCount(wordCount);
}
updateWordCount(count) {
document.querySelector('.word-count').textContent = count + ' words';
}
destroy() {
this.observer.disconnect();
}
}
const editor = new RichEditor(document.querySelector('#editor'));
Handling Bulk DOM Updates
When multiple mutations occur in quick succession, Mutation Observer batches them into a single callback:
const observer = new MutationObserver((mutations) => {
console.log('Batched ' + mutations.length + ' mutations');
});
observer.observe(target, { childList: true });
target.appendChild(document.createElement('div'));
target.appendChild(document.createElement('div'));
target.appendChild(document.createElement('div'));
This batching is intentional and helps performance.
Cleaning Up
Always disconnect observers when they are no longer needed:
observer.disconnect();
const records = observer.takeRecords();
observer.disconnect();
Common Pitfalls
Watching too much
Avoid watching the entire document with subtree true unless necessary:
observer.observe(document.querySelector('#app'), { subtree: true });
Forgetting to disconnect
Mutation Observers that persist after component destruction cause memory leaks. Always clean up in component unmount.
Over-responding
Since mutations are batched, you might receive many changes at once. Debounce your response if needed.
Browser Support
Mutation Observer is supported in all modern browsers including Chrome, Firefox, Safari, and Edge.
See Also
-
String.prototype.includes() — Check if string contains substring
-
MDN: Resize Observer API — Observe element size changes
-
MDN: Intersection Observer API — Detect when elements enter the viewport
-
Working with the DOM — DOM manipulation basics
Using with setTimeout for Delayed Reactions
When you need to react to mutations after a delay, combine Mutation Observer with setTimeout. This is useful when you want to wait for multiple rapid changes to settle before responding:
let timeoutId;
const observer = new MutationObserver((mutations) => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
console.log('Mutations settled:', mutations.length);
processMutations(mutations);
}, 300);
});
observer.observe(target, { childList: true, subtree: true });
This pattern prevents your handler from firing excessively during bulk DOM operations like rendering a large list.
Performance Considerations
Mutation Observer is significantly more efficient than the deprecated Mutation Events, but you should still be mindful of what you observe:
- Narrow your observation scope to the smallest container possible
- Use attributeFilter to watch only specific attributes
- Disconnect observers when done to free memory
- Consider using characterDataOldValue only when you need the old value
The observer callback runs asynchronously, which means the browser batches multiple mutations together. This is good for performance but means you cannot rely on receiving each individual mutation as it happens.