MutationObserver Cost
How MutationObserver works as a microtask, its performance cost at different observation scopes, characterData and attribute tracking, attributeFilter for targeted observation, takeRecords() for synchronous flushing, MutationObserver vs ResizeObserver, and patterns for React integration.
Overview
MutationObserver lets you watch for changes to the DOM — added nodes, removed nodes, attribute changes, and text content updates. It's the modern replacement for the deprecated MutationEvents API and is widely used in frameworks, analytics tools, and custom component libraries.
The problem is that it's easy to misuse. Observing too broadly, reacting too eagerly, or forgetting to disconnect observers quietly drains rendering performance in large, dynamic UIs.
How It Works
When you create a MutationObserver, you provide a callback and configuration describing what to watch. The browser queues MutationRecord objects during DOM operations and flushes them to your callback as a microtask — after the current task completes but before the next macrotask (like setTimeout).
DOM mutation happens
↓
Browser batches MutationRecords
↓
Current task finishes
↓
Microtask queue drains → your callback fires
↓
Paint / next macrotaskThe callback runs on the main thread. A slow or overly triggered callback blocks rendering.
Configuration Options
| Option | Type | What it observes |
|---|---|---|
childList | boolean | Added/removed child nodes |
attributes | boolean | Attribute changes on the target |
characterData | boolean | Text node content changes |
subtree | boolean | All descendants, not just direct children |
attributeOldValue | boolean | Record the previous attribute value |
characterDataOldValue | boolean | Record the previous text content |
attributeFilter | string[] | Only observe specific named attributes |
The cost scales with how broadly you observe:
- Observation scope —
subtree: trueon a high-level node means every descendant change triggers evaluation. - Callback complexity — iterating hundreds of records per frame is expensive.
- Observer leaks — failing to call
.disconnect()keeps the observer alive indefinitely.
Code Examples
Basic Observer — Scoped and Intentional
const container = document.getElementById("comments-feed");
if (!container) throw new Error("Target element not found");
const observer = new MutationObserver((records) => {
for (const record of records) {
if (record.type === "childList") {
for (const node of record.addedNodes) {
if (node instanceof HTMLElement && node.matches(".comment-item")) {
console.log("New comment:", node.textContent?.slice(0, 50));
}
}
}
}
});
observer.observe(container, {
childList: true, // watch for added/removed children
subtree: false, // only direct children — not the full subtree
attributes: false, // we don't care about attribute changes
});
// Always disconnect when done
function cleanup() {
observer.disconnect();
}Tracking Attribute Changes with attributeFilter
Without attributeFilter, any attribute change on any observed node fires the callback. Filter to only the attributes you care about:
const button = document.getElementById("submit-btn");
const observer = new MutationObserver((records) => {
for (const record of records) {
if (record.type === "attributes") {
const el = record.target as HTMLButtonElement;
// record.attributeName tells you WHICH attribute changed
console.log(`Attribute changed: ${record.attributeName}`);
// record.oldValue gives the previous value (requires attributeOldValue: true)
console.log(`Previous value: ${record.oldValue}`);
console.log(`New value: ${el.getAttribute(record.attributeName!)}`);
}
}
});
button?.observe &&
observer.observe(button, {
attributes: true,
// Only fire when 'disabled' or 'aria-pressed' change — ignore class, style, data-*
attributeFilter: ["disabled", "aria-pressed"],
attributeOldValue: true, // capture previous value for diffing
});Tracking Text Content Changes with characterData
const editableDiv = document.getElementById("rich-editor");
const observer = new MutationObserver((records) => {
for (const record of records) {
if (record.type === "characterData") {
const textNode = record.target as Text;
// record.oldValue requires characterDataOldValue: true
console.log("Text changed from:", record.oldValue);
console.log("Text changed to:", textNode.data);
}
}
});
editableDiv?.childNodes.forEach((node) => {
if (node.nodeType === Node.TEXT_NODE) {
observer.observe(node, {
characterData: true,
characterDataOldValue: true,
});
}
});characterData observes Text nodes directly — not element nodes. To observe
text changes across an editable element's entire subtree, combine
characterData: true with subtree: true. This fires on every keystroke in a
contenteditable element, so debounce the callback.
takeRecords() — Synchronous Flush
takeRecords() synchronously extracts any queued records from the observer's internal buffer and returns them — clearing the queue. The callback will not fire for those records.
// Use case: you need to process pending mutations synchronously
// before performing a DOM operation that depends on their result
const observer = new MutationObserver((records) => {
processRecords(records);
});
observer.observe(target, { childList: true });
// Later — you need to process mutations NOW, not in the next microtask
function doSomethingThatRequiresPendingMutations() {
// Pull any not-yet-delivered records synchronously
const pending = observer.takeRecords();
if (pending.length > 0) {
// Process immediately — callback will NOT fire for these records
processRecords(pending);
}
// Now safe to proceed knowing the state is consistent
updateDerivedState();
}
function processRecords(records: MutationRecord[]) {
for (const record of records) {
console.log("Processing:", record.type, record.addedNodes.length);
}
}Expensive vs Efficient Pattern
// ❌ BAD — watches the entire document with all mutation types
const badObserver = new MutationObserver(() => {
// Fires for every DOM change site-wide — extremely expensive
document.querySelectorAll(".highlight").forEach((el) => {
el.style.background = "yellow"; // modifying observed nodes → feedback loop risk
});
});
badObserver.observe(document.body, {
subtree: true,
childList: true,
attributes: true, // all attributes, all descendants
});
// ✅ GOOD — scoped target, specific config, debounced callback
const notifList = document.getElementById("notification-list");
let debounceTimer: ReturnType<typeof setTimeout>;
const goodObserver = new MutationObserver(() => {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
// Only runs after the burst settles — much cheaper
console.log("Notification list updated");
}, 50);
});
notifList &&
goodObserver.observe(notifList, {
childList: true,
subtree: false, // direct children only
attributes: false, // no attribute watching
});React Integration
// components/CommentFeed.tsx
"use client";
import { useEffect, useRef } from "react";
export function CommentFeed() {
const feedRef = useRef<HTMLUListElement>(null);
useEffect(() => {
const el = feedRef.current;
if (!el) return;
const observer = new MutationObserver((records) => {
const added = records.flatMap((r) => Array.from(r.addedNodes));
const newComments = added.filter(
(n): n is HTMLElement =>
n instanceof HTMLElement && n.dataset.type === "comment",
);
if (newComments.length > 0) {
console.log(`${newComments.length} new comment(s) appended`);
}
});
observer.observe(el, { childList: true, subtree: false });
// Cleanup on unmount — critical to prevent memory leaks across route transitions
return () => observer.disconnect();
}, []);
return <ul ref={feedRef} id="comment-feed" />;
}In React, prefer managing state reactively over using MutationObserver to
watch your own component's DOM. MutationObserver is best suited for
observing DOM changes from third-party code, native browser behaviour, or
elements outside your component's control. Watching your own React-rendered
DOM with MutationObserver is a sign of a state management problem.
MutationObserver vs ResizeObserver
A common mistake is using MutationObserver to detect when an element's size changes. Use ResizeObserver instead:
// ❌ MutationObserver does NOT detect size changes
// It only detects DOM structure and attribute mutations
const mo = new MutationObserver(() => {
const size = element.getBoundingClientRect(); // forced layout read
console.log("Maybe the size changed?"); // unreliable — fires on unrelated mutations
});
mo.observe(element, { attributes: true, subtree: true }); // wrong tool
// ✅ ResizeObserver — fires specifically when layout size changes
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
console.log(`New dimensions: ${width}×${height}`);
// entry.borderBoxSize and entry.contentBoxSize are also available
// for more precise measurements
}
});
ro.observe(element);
// Cleanup
ro.disconnect();| Need to detect | Use |
|---|---|
| Child nodes added/removed | MutationObserver with childList |
| Attribute changes | MutationObserver with attributes |
| Text content changes | MutationObserver with characterData |
| Element resizing | ResizeObserver |
| Element visibility | IntersectionObserver |
| Scroll position | IntersectionObserver with rootMargin |
Real-World Use Case
In a live chat application, new messages arrive via WebSocket and are injected into the DOM by a third-party widget you don't control. You need to detect new messages to update a notification badge — without polling and without modifying the widget's source.
Use MutationObserver scoped to the chat container, childList: true only (no attribute watching, no subtree), and debounce the callback to batch rapid message bursts:
const chatContainer = document.querySelector(".chat-messages");
let badge = 0;
let timer: ReturnType<typeof setTimeout>;
const observer = new MutationObserver(() => {
clearTimeout(timer);
timer = setTimeout(() => {
badge++;
updateBadge(badge);
}, 50);
});
chatContainer &&
observer.observe(chatContainer, {
childList: true,
subtree: false, // the widget adds messages as direct children
});Common Mistakes / Gotchas
1. Forgetting to disconnect. Observers run for the page's lifetime. In SPAs with route transitions, disconnected-but-alive observers accumulate across page navigations. Always disconnect in useEffect return or equivalent teardown.
2. Mutating observed nodes inside the callback. Adding a class, attribute, or child to a node that's being observed re-fires the callback. This creates infinite loops or microtask storms. Guard with a processing flag or scope observations to exclude the nodes you modify.
3. Using subtree: true as a default. On large component trees, every DOM change in the entire subtree generates records. Start with subtree: false; opt into subtree observation only when you have a specific reason and understand the cost.
4. Not batching callback work. MutationObserver callbacks can fire dozens of times during a bulk DOM update (a virtual list rendering 200 items). If each callback triggers a layout read (getBoundingClientRect), you're causing layout thrashing. Debounce with setTimeout or batch using requestAnimationFrame.
5. Using MutationObserver to detect element size changes. It doesn't observe layout dimensions. Use ResizeObserver for that.
Summary
MutationObserver fires asynchronously as a microtask after DOM mutations — never synchronously. Performance cost scales with observation scope: subtree: true on a top-level element is dramatically more expensive than a scoped shallow observer. Always disconnect in cleanup functions. Use attributeFilter to restrict attribute observation to only the attributes you need; use characterData with characterDataOldValue for text-tracking use cases. Call takeRecords() to synchronously flush pending records before operations that depend on current mutation state. Use ResizeObserver for size changes and IntersectionObserver for visibility — MutationObserver is for DOM structural and attribute mutations only. In React, prefer state-driven approaches over observing your own component's DOM.
Interview Questions
Q1. How does MutationObserver deliver its callbacks relative to the JavaScript task queue?
MutationObserver callbacks are delivered as microtasks — they run after the current JavaScript task completes but before the next macrotask (like setTimeout, setInterval, or a new event). When DOM mutations occur during a task, the browser batches all resulting MutationRecord objects. Once the current task finishes, the microtask queue drains — and that's when your callback fires with the accumulated records. This means mutations from a single synchronous operation (e.g., appending 100 items in a loop) are all delivered in one callback invocation, not one per mutation. This is more efficient than MutationEvents which fired synchronously on every mutation, causing re-entrancy problems and synchronous layout recalculations.
Q2. What does observer.takeRecords() do and when would you use it?
takeRecords() synchronously extracts all pending MutationRecord objects that have been queued but not yet delivered to the callback. The internal buffer is cleared — the callback will not fire for those records. It's used when you need to process pending mutations synchronously before performing a DOM operation that depends on the current mutation state, rather than waiting for the microtask queue to drain. For example, before disconnecting an observer during a cleanup phase, you might call takeRecords() to ensure you haven't missed any records that accumulated between the last callback invocation and the disconnect call.
Q3. What is attributeFilter and why is it important for performance?
attributeFilter is an array of attribute names. When provided, MutationObserver only generates MutationRecord entries for changes to those specific attributes, ignoring all others. Without it, every attribute change on every observed node fires the callback — in a React app, this includes class, style, data-*, aria-*, and any dynamically set attributes from third-party scripts. On a node with subtree: true, this can generate thousands of records per second. attributeFilter: ["disabled", "aria-expanded"] limits observation to exactly those two attributes, reducing callback frequency dramatically and making the observer's purpose explicit to future readers.
Q4. When should you use MutationObserver versus ResizeObserver versus IntersectionObserver?
Each solves a different problem. MutationObserver detects structural DOM changes — nodes added or removed, attribute values changed, text content changed. It tells you what changed in the DOM tree. ResizeObserver detects when an element's layout dimensions change — width, height, border-box, content-box. It fires when CSS changes, parent container resizes, or content reflows change an element's rendered size. IntersectionObserver detects when an element crosses a visibility threshold relative to the viewport or a scrollable ancestor. The classic mistake is using MutationObserver with subtree: true and attributes: true to detect size changes — it doesn't work. MutationObserver has no visibility into layout dimensions; only ResizeObserver does.
Q5. How do you prevent an infinite feedback loop when modifying observed nodes inside a MutationObserver callback?
The loop occurs when your callback modifies a node that's being observed, which queues another mutation, which calls the callback again. Three approaches: first, scope the observation tightly enough to exclude the nodes you modify — if you observe container for childList but modify attributes inside it, there's no conflict. Second, use a boolean guard: let processing = false; if (processing) return; processing = true; /* work */ processing = false;. Third, call observer.disconnect() before modifying, then reconnect after. Each approach has trade-offs: scoping is cleanest but not always possible; the flag approach has edge cases with async code; disconnect/reconnect misses mutations during the gap. Tight scoping with attributeFilter to exclude the attributes you set is usually the most robust.
Q6. In a React application, when is MutationObserver the right tool versus React state management?
MutationObserver is appropriate in React when you need to react to DOM changes from code outside your control: a third-party widget injecting content, native browser behaviour (browser extensions modifying the DOM), or a contenteditable element where text changes come from user input to native DOM APIs rather than React state. It's also appropriate when integrating with non-React libraries that manipulate DOM directly. It is NOT the right tool for observing changes to your own React-rendered components — if you find yourself using MutationObserver to detect when your own component's DOM updates, you're working around React's state and rendering model rather than with it. The correct approach is to drive changes through state (useState, useReducer, Zustand, etc.) and derive what you need from that state. Using MutationObserver on React-managed DOM creates fragile coupling between the observer's timing and React's rendering schedule.
IntersectionObserver Internals
How the browser detects element visibility against a viewport or ancestor — thresholds, root margins, entry data, lazy loading, infinite scroll, analytics impression tracking, disconnect vs unobserve, and the observation loop internals.
PerformanceObserver API
The unified API for subscribing to browser performance entries — navigation timing, resource timing, paint timing, long tasks, layout shift, LCP, and Core Web Vitals. buffered flag, takeRecords(), entry type reference, and reporting patterns.