Skip to main content

How to Use MutationObserver to Watch for DOM Changes in JavaScript

The DOM is not static. Modern web applications constantly add, remove, and modify elements: frameworks render components, third-party scripts inject widgets, user interactions trigger dynamic updates, and AJAX calls populate content after page load. Sometimes you need to know when these changes happen, whether to react to content injected by code you do not control, to synchronize your UI with external modifications, or to build tools that adapt to a changing document.

MutationObserver is the modern, performant API for watching DOM changes. It replaces the older, deprecated Mutation Events (DOMNodeInserted, DOMSubtreeModified, etc.) with an asynchronous, batched approach that does not degrade page performance. This guide covers how MutationObserver works, how to configure it for different types of changes, how to manage its lifecycle, and practical use cases where it solves real problems.

What Is MutationObserver?

MutationObserver is a built-in JavaScript API that lets you watch a DOM node for changes and execute a callback when modifications occur. It observes three categories of changes:

  • Child list changes: Elements or text nodes added to or removed from a parent
  • Attribute changes: An attribute value on an element is modified, added, or removed
  • Character data changes: The text content of a text node or comment node changes

Unlike event listeners that fire synchronously during DOM manipulation, MutationObserver callbacks are asynchronous. The browser collects all mutations that happen during a JavaScript execution cycle and delivers them as a batch in a single callback invocation. This batched delivery is what makes MutationObserver far more performant than the old Mutation Events.

Basic Structure

The API follows a simple pattern:

  1. Create an observer with a callback function
  2. Tell it what to watch (a target node and what kinds of changes)
  3. The callback receives a list of mutation records when changes occur
  4. Disconnect the observer when you no longer need it
// 1. Create the observer
const observer = new MutationObserver((mutations) => {
// 3. React to changes
for (const mutation of mutations) {
console.log('Something changed:', mutation.type);
}
});

// 2. Start observing
observer.observe(document.getElementById('target'), {
childList: true,
attributes: true
});

// 4. Stop observing when done
// observer.disconnect();

observe(target, config): Watching for DOM Changes

The observe() method starts watching a specific DOM node. It takes two arguments: the target node to observe and a configuration object that specifies which types of mutations to detect.

const observer = new MutationObserver(callback);

observer.observe(targetNode, {
childList: true, // Watch for added/removed child nodes
attributes: true, // Watch for attribute changes
characterData: true // Watch for text content changes
});

You must specify at least one of childList, attributes, or characterData as true. Omitting all three (or setting them all to false) throws a TypeError:

// ❌ TypeError: At least one of childList, attributes, or characterData must be true
observer.observe(target, {});

// ✅ Valid: watching for child list changes
observer.observe(target, { childList: true });

The Callback Function

The callback receives two arguments: an array of MutationRecord objects describing each change, and a reference to the observer itself.

const observer = new MutationObserver((mutations, obs) => {
for (const mutation of mutations) {
console.log('Type:', mutation.type);
console.log('Target:', mutation.target);
}
});

Each MutationRecord contains detailed information about a single change:

PropertyDescription
type"childList", "attributes", or "characterData"
targetThe node that was mutated
addedNodesNodeList of added child nodes (for childList)
removedNodesNodeList of removed child nodes (for childList)
previousSiblingThe previous sibling of added/removed nodes
nextSiblingThe next sibling of added/removed nodes
attributeNameName of the changed attribute (for attributes)
attributeNamespaceNamespace of the changed attribute
oldValuePrevious value (if attributeOldValue or characterDataOldValue is true)

Watching for Child Node Changes

The most common use case is detecting when elements are added to or removed from a container:

<ul id="todo-list">
<li>Existing item</li>
</ul>
<button id="add-btn">Add Item</button>

<script>
const list = document.getElementById('todo-list');

const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'childList') {
mutation.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
console.log('Added:', node.textContent);
}
});

mutation.removedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
console.log('Removed:', node.textContent);
}
});
}
}
});

observer.observe(list, { childList: true });

// Test it
document.getElementById('add-btn').addEventListener('click', () => {
const li = document.createElement('li');
li.textContent = `Item ${list.children.length + 1}`;
list.appendChild(li);
});
</script>

Clicking the button produces:

Added: Item 2
note

addedNodes and removedNodes are NodeList objects that can contain text nodes, element nodes, and comment nodes. Always check node.nodeType === Node.ELEMENT_NODE (or node.nodeType === 1) if you only care about elements. Whitespace text nodes are common and can cause unexpected results if not filtered.

Watching for Attribute Changes

Observe when attributes are modified on an element:

const button = document.getElementById('my-button');

const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'attributes') {
console.log(`Attribute "${mutation.attributeName}" changed on`, mutation.target);
console.log('New value:', mutation.target.getAttribute(mutation.attributeName));
}
}
});

observer.observe(button, { attributes: true });

// Test it
button.disabled = true;
// Logs: Attribute "disabled" changed
button.classList.add('active');
// Logs: Attribute "class" changed
button.setAttribute('data-state', 'loading');
// Logs: Attribute "data-state" changed

Watching for Text Content Changes

The characterData option watches for changes to the text content of text nodes and comment nodes:

<p id="editable" contenteditable="true">Edit this text</p>

<script>
const paragraph = document.getElementById('editable');
// We need to observe the text node inside the paragraph, not the paragraph itself
const textNode = paragraph.firstChild;

const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'characterData') {
console.log('Text changed to:', mutation.target.data);
}
}
});

observer.observe(textNode, { characterData: true });
</script>
warning

characterData monitors changes to the content of text nodes, not to textContent or innerHTML of element nodes. Setting element.textContent = "new text" actually removes the old text node and creates a new one, which is a childList mutation on the parent, not a characterData mutation. To catch that, observe the parent with childList: true or use subtree: true with characterData: true.

Config Options: childList, attributes, characterData, subtree

The configuration object gives you fine-grained control over exactly what changes to observe. Here is the complete set of options:

Core Options

OptionTypeDefaultDescription
childListbooleanfalseWatch for added/removed child nodes
attributesbooleanfalseWatch for attribute changes
characterDatabooleanfalseWatch for text node content changes

Scope Option

OptionTypeDefaultDescription
subtreebooleanfalseWatch the target and all its descendants

Detail Options

OptionTypeDefaultDescription
attributeOldValuebooleanfalseRecord the previous attribute value
characterDataOldValuebooleanfalseRecord the previous text content
attributeFilterstring[](all)Only watch specific attributes

subtree: Watching the Entire Tree

Without subtree, the observer only watches the target node's direct children (for childList) or the target itself (for attributes and characterData). With subtree: true, the observer watches the target and every descendant node, no matter how deeply nested.

// Without subtree: only direct children of #container
observer.observe(container, { childList: true });

// With subtree: any node added/removed anywhere inside #container
observer.observe(container, { childList: true, subtree: true });

This is particularly useful for watching the entire document:

// Watch for any DOM change anywhere in the document
const observer = new MutationObserver((mutations) => {
console.log(`${mutations.length} mutations detected`);
});

observer.observe(document.body, {
childList: true,
attributes: true,
characterData: true,
subtree: true
});
caution

Observing document.body with subtree: true and all mutation types enabled catches every DOM change on the page. This is powerful but can generate a large number of mutation records. Keep your callback lightweight to avoid performance issues. Process only the mutations you care about and ignore the rest.

attributeOldValue: Tracking Previous Attribute Values

By default, the mutation record tells you which attribute changed but not what its previous value was. Enable attributeOldValue to capture it:

const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'attributes') {
console.log(`"${mutation.attributeName}" changed`);
console.log(` Old value: ${mutation.oldValue}`);
console.log(` New value: ${mutation.target.getAttribute(mutation.attributeName)}`);
}
}
});

observer.observe(element, {
attributes: true,
attributeOldValue: true
});

// Test it
element.setAttribute('data-status', 'loading');
// Old value: null (attribute didn't exist)
// New value: loading

element.setAttribute('data-status', 'complete');
// Old value: loading
// New value: complete

Setting attributeOldValue: true implicitly enables attributes: true, so you do not need to specify both.

characterDataOldValue: Tracking Previous Text

Similarly, characterDataOldValue records the previous content of text nodes:

observer.observe(textNode, {
characterData: true,
characterDataOldValue: true
});

The mutation record's oldValue will contain the text before the change.

attributeFilter: Watching Specific Attributes

If you only care about specific attributes, attributeFilter limits the observer to only those attribute names:

const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
console.log(`${mutation.attributeName} changed`);
}
});

// Only watch for class and data-state changes, ignore everything else
observer.observe(element, {
attributeFilter: ['class', 'data-state']
});

element.classList.add('active'); // Triggers callback
element.setAttribute('data-state', 'on'); // Triggers callback
element.setAttribute('id', 'new-id'); // Ignored
element.setAttribute('title', 'hello'); // Ignored

Setting attributeFilter implicitly enables attributes: true.

Configuration Examples for Common Scenarios

// Watch for elements being added/removed anywhere in the document
observer.observe(document.body, {
childList: true,
subtree: true
});

// Watch for class changes on a specific element
observer.observe(element, {
attributeFilter: ['class']
});

// Watch for any attribute change and record old values
observer.observe(element, {
attributes: true,
attributeOldValue: true
});

// Watch for all changes inside a container
observer.observe(container, {
childList: true,
attributes: true,
characterData: true,
subtree: true
});

// Watch for specific data attributes with old values
observer.observe(element, {
attributeFilter: ['data-loading', 'data-error', 'data-count'],
attributeOldValue: true
});

Observing Multiple Targets

A single MutationObserver can watch multiple targets by calling observe() multiple times. Each call adds a new observation without replacing the previous ones:

const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
console.log('Change in:', mutation.target.id);
}
});

// Watch three separate elements
observer.observe(document.getElementById('panel-a'), { childList: true });
observer.observe(document.getElementById('panel-b'), { childList: true });
observer.observe(document.getElementById('panel-c'), { attributes: true });

However, calling observe() on the same target a second time replaces the configuration for that target:

// First call: watch for child changes
observer.observe(element, { childList: true });

// Second call on the same element: replaces the first config
// Now only watching attributes, NOT childList
observer.observe(element, { attributes: true });

To watch the same element for multiple types, combine them in a single observe() call:

// ✅ Correct: one observe call with all needed options
observer.observe(element, {
childList: true,
attributes: true,
characterData: true
});

disconnect() and takeRecords()

disconnect(): Stopping the Observer

The disconnect() method stops the observer from watching all targets. After disconnecting, the callback will not fire for any further mutations. Any pending, undelivered mutations are discarded.

const observer = new MutationObserver((mutations) => {
console.log('Change detected');
});

observer.observe(container, { childList: true, subtree: true });

// Later, when you no longer need to watch
observer.disconnect();

// Mutations after disconnect are not observed
container.appendChild(document.createElement('div')); // No callback

After disconnecting, you can reuse the same observer by calling observe() again:

observer.disconnect();

// Re-observe with potentially different config or target
observer.observe(newTarget, { attributes: true });

When to Disconnect

Always disconnect observers that are no longer needed. Common scenarios include:

// Single-use observation: disconnect after finding what you need
const observer = new MutationObserver((mutations, obs) => {
for (const mutation of mutations) {
const targetElement = mutation.target.querySelector('.dynamic-content');
if (targetElement) {
console.log('Found the element!');
initializeWidget(targetElement);
obs.disconnect(); // Done watching
return;
}
}
});

observer.observe(document.body, { childList: true, subtree: true });
// Component lifecycle: disconnect when component is destroyed
class DynamicComponent {
constructor(element) {
this.element = element;
this.observer = new MutationObserver(this.handleMutations.bind(this));
this.observer.observe(element, { childList: true, subtree: true });
}

handleMutations(mutations) {
// React to changes...
}

destroy() {
this.observer.disconnect(); // Clean up
this.element.remove();
}
}

takeRecords(): Processing Pending Mutations

The takeRecords() method returns any mutations that have been detected but not yet delivered to the callback. After calling takeRecords(), those records are removed from the queue and the callback will not receive them.

const observer = new MutationObserver((mutations) => {
console.log('Callback received:', mutations.length, 'mutations');
});

observer.observe(container, { childList: true });

// Make changes synchronously
container.appendChild(document.createElement('div'));
container.appendChild(document.createElement('span'));
container.appendChild(document.createElement('p'));

// These mutations are queued but the callback hasn't fired yet
// (callbacks are microtasks, delivered after the current synchronous code)

// Grab the pending records before the callback fires
const pendingRecords = observer.takeRecords();
console.log('Took records:', pendingRecords.length);
// Output: Took records: 3

// The callback will NOT receive these mutations since we already took them

A common pattern is to call takeRecords() before disconnect() to process any final mutations:

function stopObserving() {
// Process any remaining mutations before disconnecting
const finalMutations = observer.takeRecords();
if (finalMutations.length > 0) {
processMutations(finalMutations);
}

observer.disconnect();
}

Understanding the Timing: Microtasks

MutationObserver callbacks are delivered as microtasks. This means they fire after the current synchronous code finishes but before the browser renders or processes macrotasks like setTimeout.

const observer = new MutationObserver(() => {
console.log('2: Observer callback (microtask)');
});

observer.observe(container, { childList: true });

console.log('1: Before mutation');
container.appendChild(document.createElement('div'));
console.log('3: After mutation (but callback hasn\'t fired yet)');

// Output order:
// 1: Before mutation
// 3: After mutation (but callback hasn't fired yet)
// 2: Observer callback (microtask)

Multiple DOM changes within the same synchronous block are batched into a single callback invocation:

const observer = new MutationObserver((mutations) => {
console.log(`Received ${mutations.length} mutations in one callback`);
});

observer.observe(container, { childList: true });

// All three changes are batched
container.appendChild(document.createElement('div'));
container.appendChild(document.createElement('span'));
container.appendChild(document.createElement('p'));

// Output: Received 3 mutations in one callback

This batching is what makes MutationObserver efficient. Instead of firing a callback for each individual change (as the old Mutation Events did), it collects all changes and delivers them at once.

Use Cases: Third-Party Widget Integration, Dynamic UI Observation

Waiting for a Dynamically Loaded Element

One of the most common real-world uses is waiting for an element to appear in the DOM. This happens when third-party scripts inject content asynchronously or when a framework renders a component after an API call:

function waitForElement(selector, timeout = 10000) {
return new Promise((resolve, reject) => {
// Check if it already exists
const existing = document.querySelector(selector);
if (existing) {
resolve(existing);
return;
}

const observer = new MutationObserver((mutations, obs) => {
const element = document.querySelector(selector);
if (element) {
obs.disconnect();
clearTimeout(timer);
resolve(element);
}
});

observer.observe(document.body, {
childList: true,
subtree: true
});

// Timeout to avoid waiting forever
const timer = setTimeout(() => {
observer.disconnect();
reject(new Error(`Element "${selector}" not found within ${timeout}ms`));
}, timeout);
});
}

// Usage
async function initializeChat() {
try {
const chatWidget = await waitForElement('#third-party-chat-widget');
console.log('Chat widget loaded:', chatWidget);
customizeChatWidget(chatWidget);
} catch (error) {
console.warn('Chat widget did not load:', error.message);
}
}

initializeChat();

This is far better than polling with setInterval:

// ❌ Polling: wasteful, imprecise timing, arbitrary interval
const interval = setInterval(() => {
const element = document.querySelector('#target');
if (element) {
clearInterval(interval);
doSomething(element);
}
}, 100); // Checks every 100ms even when nothing changes

// ✅ MutationObserver: efficient, fires exactly when the DOM changes
waitForElement('#target').then(doSomething);

Reacting to Third-Party Script Modifications

Third-party scripts (ads, analytics, chat widgets, embedded content) often modify your DOM in ways you cannot control. MutationObserver lets you react to their changes:

// Watch for an ad network injecting unwanted styles
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
const target = mutation.target;
// Remove inline styles injected by a third-party script
if (target.classList.contains('my-content') && target.style.display === 'none') {
target.style.display = '';
console.log('Reverted unwanted style change from third-party script');
}
}
}
});

observer.observe(document.getElementById('main-content'), {
attributes: true,
attributeFilter: ['style'],
subtree: true
});

Auto-Initializing Components on Dynamic Content

When your application dynamically adds HTML (from AJAX calls, template rendering, or framework updates), you may need to initialize JavaScript behavior on the new elements:

// Auto-initialize tooltips, date pickers, etc. on dynamically added elements
const componentObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType !== Node.ELEMENT_NODE) continue;

// Initialize tooltips on elements with data-tooltip
const tooltipElements = node.querySelectorAll('[data-tooltip]');
node.matches?.('[data-tooltip]') && initTooltip(node);
tooltipElements.forEach(initTooltip);

// Initialize date pickers
const datePickers = node.querySelectorAll('.date-picker');
node.matches?.('.date-picker') && initDatePicker(node);
datePickers.forEach(initDatePicker);

// Initialize lazy images
const lazyImages = node.querySelectorAll('img[data-src]');
node.matches?.('img[data-src]') && observeLazyImage(node);
lazyImages.forEach(observeLazyImage);
}
}
});

componentObserver.observe(document.body, {
childList: true,
subtree: true
});

function initTooltip(el) {
console.log('Tooltip initialized on:', el);
}

function initDatePicker(el) {
console.log('Date picker initialized on:', el);
}

function observeLazyImage(img) {
console.log('Lazy image queued:', img.dataset.src);
}

This approach ensures that any dynamically added element gets initialized automatically, regardless of how it was added to the DOM.

Tracking Content Changes for Undo/Redo

MutationObserver can be used to build an undo/redo system for contenteditable areas or dynamic interfaces:

class UndoManager {
constructor(target) {
this.target = target;
this.history = [];
this.currentIndex = -1;
this.isUndoRedo = false;

// Save initial state
this.saveState();

this.observer = new MutationObserver(() => {
if (this.isUndoRedo) return; // Skip changes caused by undo/redo

this.saveState();
});

this.observer.observe(target, {
childList: true,
attributes: true,
characterData: true,
subtree: true
});
}

saveState() {
// Discard any "future" states if we're in the middle of the history
this.history = this.history.slice(0, this.currentIndex + 1);

this.history.push(this.target.innerHTML);
this.currentIndex = this.history.length - 1;

// Limit history size
if (this.history.length > 50) {
this.history.shift();
this.currentIndex--;
}
}

undo() {
if (this.currentIndex <= 0) return;

this.isUndoRedo = true;
this.currentIndex--;
this.target.innerHTML = this.history[this.currentIndex];
this.isUndoRedo = false;
}

redo() {
if (this.currentIndex >= this.history.length - 1) return;

this.isUndoRedo = true;
this.currentIndex++;
this.target.innerHTML = this.history[this.currentIndex];
this.isUndoRedo = false;
}

destroy() {
this.observer.disconnect();
}
}

// Usage
const editor = document.getElementById('editor');
const undoManager = new UndoManager(editor);

document.addEventListener('keydown', (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
e.preventDefault();
if (e.shiftKey) {
undoManager.redo();
} else {
undoManager.undo();
}
}
});

Monitoring Element Visibility via Class Changes

Watching for CSS class changes to synchronize behavior:

const modal = document.getElementById('modal');

const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.attributeName === 'class') {
const isVisible = modal.classList.contains('open');
if (isVisible) {
console.log('Modal opened');
trapFocus(modal); // Accessibility: trap focus inside modal
disableBodyScroll();
} else {
console.log('Modal closed');
releaseFocus();
enableBodyScroll();
}
}
}
});

observer.observe(modal, {
attributeFilter: ['class']
});

Building a Live Node Counter

A practical debugging tool that tracks the number of DOM nodes in real time:

function createNodeCounter(root = document.body) {
const display = document.createElement('div');
display.style.cssText = `
position: fixed; bottom: 10px; right: 10px; padding: 8px 14px;
background: #333; color: #0f0; font-family: monospace; font-size: 13px;
border-radius: 6px; z-index: 99999;
`;
document.body.appendChild(display);

function updateCount() {
const count = root.querySelectorAll('*').length;
display.textContent = `DOM nodes: ${count}`;
}

updateCount();

const observer = new MutationObserver(() => {
updateCount();
});

observer.observe(root, {
childList: true,
subtree: true
});

return {
disconnect() {
observer.disconnect();
display.remove();
}
};
}

// Usage in DevTools console:
const counter = createNodeCounter();
// Later: counter.disconnect();

Performance Considerations

While MutationObserver is far more efficient than Mutation Events, careless use can still impact performance:

// ❌ Heavy work inside the callback on every mutation
const observer = new MutationObserver((mutations) => {
// This runs for EVERY batch of changes
document.querySelectorAll('.item').forEach(item => {
// Expensive DOM queries and manipulation on every mutation
recalculateLayout(item);
});
});

// ✅ Filter mutations and do minimal work
const observer = new MutationObserver((mutations) => {
let needsUpdate = false;

for (const mutation of mutations) {
if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
needsUpdate = true;
break; // No need to check further
}
}

if (needsUpdate) {
requestAnimationFrame(() => {
recalculateLayout(); // Batch with rendering
});
}
});

Key performance tips:

  • Use attributeFilter to limit which attributes trigger callbacks.
  • Avoid subtree: true on large trees unless necessary.
  • Filter mutations early in your callback and skip irrelevant ones.
  • Batch DOM reads and writes using requestAnimationFrame inside the callback.
  • Disconnect when done. Do not leave observers running indefinitely if they have served their purpose.
// ✅ Targeted observation: only watch what you need
observer.observe(specificContainer, {
childList: true,
attributeFilter: ['data-status'],
// No subtree, no characterData - minimal scope
});
tip

If you find yourself observing document.body with all options enabled and subtree: true, step back and ask if you can narrow the scope. Observing a specific container or filtering to specific attributes dramatically reduces the number of mutation records your callback has to process.

Summary

MutationObserver is the standard, performant way to react to DOM changes in JavaScript:

  • Create an observer with new MutationObserver(callback) and start watching with observer.observe(target, config).
  • The config object controls what to watch: childList for added/removed nodes, attributes for attribute changes, and characterData for text node changes. Add subtree: true to watch the entire descendant tree.
  • Use attributeFilter to limit attribute observation to specific names. Enable attributeOldValue or characterDataOldValue to capture previous values in mutation records.
  • Callbacks are delivered as microtasks, batching all mutations from a synchronous code block into a single callback invocation.
  • Call disconnect() to stop observing and free resources. Use takeRecords() to retrieve pending mutations before disconnecting.
  • Common real-world uses include waiting for dynamically loaded elements, reacting to third-party script modifications, auto-initializing components on injected HTML, building undo/redo systems, and synchronizing UI state with DOM changes.

Always disconnect observers when they are no longer needed, keep callbacks lightweight, and scope your observations as narrowly as possible for best performance.