Skip to content

Mutation Observer — Complete Guide

DodaTech Updated 2026-06-28 7 min read

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

  1. 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.
  2. Forgetting to disconnect observers — MutationObserver keeps a reference to the callback and target. Not disconnecting causes memory leaks, especially in SPAs.
  3. 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.
  4. Assuming characterData watches element textContent — characterData observes #text nodes directly. For contenteditable or elements with text children, use subtree: true and characterData: true.
  5. Processing mutations synchronously for large batches — Mutation records can accumulate rapidly. Batch processing with requestAnimationFrame prevents main thread blocking.

Practice Questions

  1. What configuration options does MutationObserver.observe accept? childList, attributes, characterData, subtree, attributeOldValue, characterDataOldValue, attributeFilter.
  2. 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.
  3. 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.
  4. 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

Can MutationObserver detect changes inside a Shadow DOM?

Yes. Observe the shadow root directly: observer.observe(shadowRoot, { childList: true, subtree: true }).

What is the difference between MutationObserver and Mutation Events?

MutationObserver is the modern replacement. Mutation Events (DOMSubtreeModified, DOMNodeInserted) are deprecated due to severe performance problems.

How many mutations can I observe before performance degrades?

The API itself is efficient. The limit depends on your callback logic. Keep callbacks lightweight and batch expensive work.

Does MutationObserver work with getRootNode?

Yes. The observer works on any Node, including document fragments and shadow roots.

Can I observe the entire document?

Yes. observer.observe(document, { childList: true, subtree: true }) watches the entire DOM tree. Be careful with performance.

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