JavaScript Mutation Observer: Complete Tutorial
A complete tutorial on the JavaScript MutationObserver API. Covers observing childList, attributes, and characterData mutations, subtree monitoring, mutation record properties, filtering with attributeFilter, disconnecting and reconnecting, observing dynamic content insertion, building a DOM change logger, undo system, and auto-initializing widgets.
MutationObserver watches for changes to the DOM tree and fires a callback with detailed records of what changed. It replaces the deprecated Mutation Events (DOMNodeInserted, DOMAttrModified) with a performant, batch-based alternative. This guide covers every configuration option and practical pattern.
Creating a MutationObserver
const observer = new MutationObserver((mutations, observer) => {
for (const mutation of mutations) {
console.log("Type:", mutation.type);
console.log("Target:", mutation.target);
}
});
// Start observing
observer.observe(document.getElementById("app"), {
childList: true,
attributes: true,
characterData: true,
subtree: true,
});
// Stop observing
observer.disconnect();
// Get pending mutations without waiting for callback
const pending = observer.takeRecords();Observation Options
| Option | Type | Default | Description |
|---|---|---|---|
childList | boolean | false | Watch for added/removed child nodes |
attributes | boolean | false | Watch for attribute changes |
characterData | boolean | false | Watch for text content changes |
subtree | boolean | false | Observe all descendants, not just direct children |
attributeOldValue | boolean | false | Record the previous attribute value |
characterDataOldValue | boolean | false | Record the previous text value |
attributeFilter | string[] | (all) | Only observe specific attribute names |
At least one of childList, attributes, or characterData must be true.
MutationRecord Properties
| Property | Type | Description |
|---|---|---|
type | string | "childList", "attributes", or "characterData" |
target | Node | The node that was mutated |
addedNodes | NodeList | Nodes added (childList only) |
removedNodes | NodeList | Nodes removed (childList only) |
previousSibling | Node or null | Node before added/removed nodes |
nextSibling | Node or null | Node after added/removed nodes |
attributeName | string or null | Name of changed attribute |
attributeNamespace | string or null | Namespace of changed attribute |
oldValue | string or null | Previous value (if configured) |
Watching Child Node Changes
const list = document.getElementById("todo-list");
const childObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type !== "childList") continue;
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
console.log("Added:", node.tagName, node.textContent);
}
});
mutation.removedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
console.log("Removed:", node.tagName, node.textContent);
}
});
}
});
childObserver.observe(list, { childList: true });
// These changes trigger the callback
list.appendChild(document.createElement("li"));
list.removeChild(list.firstElementChild);
list.innerHTML = "<li>New content</li>";Watching Attribute Changes
const element = document.getElementById("theme-toggle");
const attrObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type !== "attributes") continue;
console.log(`Attribute "${mutation.attributeName}" changed`);
console.log("Old value:", mutation.oldValue);
console.log("New value:", mutation.target.getAttribute(mutation.attributeName));
}
});
attrObserver.observe(element, {
attributes: true,
attributeOldValue: true,
attributeFilter: ["class", "data-theme", "aria-expanded"],
});
// Triggers callback
element.setAttribute("data-theme", "dark");
element.classList.add("active");Watching Text Content Changes
const textNode = document.getElementById("editable-text").firstChild;
const textObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type !== "characterData") continue;
console.log("Text changed");
console.log("Old:", mutation.oldValue);
console.log("New:", mutation.target.textContent);
}
});
textObserver.observe(textNode, {
characterData: true,
characterDataOldValue: true,
});DOM Change Logger
class DOMChangeLogger {
constructor(root, options = {}) {
this.root = typeof root === "string" ? document.querySelector(root) : root;
this.log = [];
this.maxEntries = options.maxEntries || 1000;
this.filter = options.filter || (() => true);
this.listeners = new Set();
this.observer = new MutationObserver((mutations) => {
this.processMutations(mutations);
});
}
start() {
this.observer.observe(this.root, {
childList: true,
attributes: true,
characterData: true,
subtree: true,
attributeOldValue: true,
characterDataOldValue: true,
});
}
stop() {
const pending = this.observer.takeRecords();
if (pending.length) this.processMutations(pending);
this.observer.disconnect();
}
processMutations(mutations) {
for (const mutation of mutations) {
const entry = {
type: mutation.type,
timestamp: Date.now(),
target: this.describeNode(mutation.target),
};
switch (mutation.type) {
case "childList":
entry.added = [...mutation.addedNodes]
.filter((n) => n.nodeType === Node.ELEMENT_NODE)
.map((n) => this.describeNode(n));
entry.removed = [...mutation.removedNodes]
.filter((n) => n.nodeType === Node.ELEMENT_NODE)
.map((n) => this.describeNode(n));
break;
case "attributes":
entry.attribute = mutation.attributeName;
entry.oldValue = mutation.oldValue;
entry.newValue = mutation.target.getAttribute(mutation.attributeName);
break;
case "characterData":
entry.oldValue = mutation.oldValue;
entry.newValue = mutation.target.textContent;
break;
}
if (this.filter(entry)) {
this.log.push(entry);
if (this.log.length > this.maxEntries) {
this.log.shift();
}
this.notify(entry);
}
}
}
describeNode(node) {
if (!node || node.nodeType !== Node.ELEMENT_NODE) {
return node ? node.textContent?.slice(0, 50) : null;
}
let desc = node.tagName.toLowerCase();
if (node.id) desc += `#${node.id}`;
if (node.className && typeof node.className === "string") {
desc += `.${node.className.split(" ").join(".")}`;
}
return desc;
}
onChange(callback) {
this.listeners.add(callback);
return () => this.listeners.delete(callback);
}
notify(entry) {
for (const listener of this.listeners) {
listener(entry);
}
}
getLog() {
return [...this.log];
}
clear() {
this.log = [];
}
}
// Usage
const logger = new DOMChangeLogger("#app", { maxEntries: 500 });
logger.onChange((entry) => {
console.log(`[${entry.type}]`, entry);
});
logger.start();Auto-Initializing Widgets
Automatically initialize components when they are added to the DOM:
class WidgetAutoInitializer {
constructor() {
this.registry = new Map();
this.initialized = new WeakSet();
this.observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
mutation.addedNodes.forEach((node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
this.initNode(node);
node.querySelectorAll("*").forEach((child) => this.initNode(child));
}
});
}
});
}
register(selector, initFn) {
this.registry.set(selector, initFn);
}
initNode(node) {
if (this.initialized.has(node)) return;
for (const [selector, initFn] of this.registry) {
if (node.matches(selector)) {
initFn(node);
this.initialized.add(node);
}
}
}
start(root = document.body) {
// Initialize existing elements
for (const [selector, initFn] of this.registry) {
root.querySelectorAll(selector).forEach((el) => {
if (!this.initialized.has(el)) {
initFn(el);
this.initialized.add(el);
}
});
}
// Watch for new elements
this.observer.observe(root, {
childList: true,
subtree: true,
});
}
stop() {
this.observer.disconnect();
}
}
// Usage
const widgets = new WidgetAutoInitializer();
widgets.register("[data-tooltip]", (el) => {
// Initialize tooltip on this element
console.log("Tooltip initialized:", el.dataset.tooltip);
});
widgets.register("[data-dropdown]", (el) => {
// Initialize dropdown
console.log("Dropdown initialized:", el.id);
});
widgets.start();
// Later: dynamically added elements are auto-initialized
document.body.innerHTML += '<button data-tooltip="Click me">Button</button>';Pausing Mutations During Batch Updates
class PausableMutationObserver {
constructor(callback, options) {
this.callback = callback;
this.options = options;
this.target = null;
this.isPaused = false;
this.pendingMutations = [];
this.observer = new MutationObserver((mutations) => {
if (this.isPaused) {
this.pendingMutations.push(...mutations);
} else {
this.callback(mutations);
}
});
}
observe(target) {
this.target = target;
this.observer.observe(target, this.options);
}
pause() {
this.isPaused = true;
}
resume() {
this.isPaused = false;
if (this.pendingMutations.length > 0) {
this.callback(this.pendingMutations);
this.pendingMutations = [];
}
}
batchUpdate(updateFn) {
this.pause();
try {
updateFn();
} finally {
this.resume();
}
}
disconnect() {
this.observer.disconnect();
this.pendingMutations = [];
}
}
// Usage
const pausable = new PausableMutationObserver(
(mutations) => console.log(`${mutations.length} mutations`),
{ childList: true, subtree: true }
);
pausable.observe(document.getElementById("app"));
// Batch many changes into one callback
pausable.batchUpdate(() => {
for (let i = 0; i < 100; i++) {
document.getElementById("app").appendChild(document.createElement("div"));
}
});
// Logs: "100 mutations" (one callback, not 100)Rune AI
Key Insights
- Batched microtask delivery: Mutations are collected and delivered in one callback after the current script finishes, not synchronously during DOM changes
- Three mutation types:
childListfor added/removed nodes,attributesfor attribute changes,characterDatafor text node changes; enable only what you need - subtree for deep observation: Set
subtree: trueto observe all descendants; without it, only direct children are watched - attributeFilter for precision: Specify
attributeFilter: ["class", "data-state"]to ignore irrelevant attribute changes and reduce callback noise - WeakSet for deduplication: Track initialized elements with a
WeakSetto prevent double-processing when new subtrees are added
Frequently Asked Questions
How is MutationObserver different from MutationEvents?
Does MutationObserver detect CSS changes?
Can I observe the entire document?
When does the MutationObserver callback fire?
How do I observe elements that do not exist yet?
Conclusion
MutationObserver provides performant, batched DOM change detection for child nodes, attributes, and text content. Use it for auto-initializing widgets, logging DOM changes, building undo systems, and reacting to third-party DOM modifications. Prefer targeted observations over document-wide watching, and batch your own updates to minimize mutation records. For viewport-based detection, see JS Intersection Observer API: complete tutorial. For tracking DOM changes in detail, see tracking DOM changes with JS Mutation Observers.
More in this topic
OffscreenCanvas API in JS for UI Performance
Master the OffscreenCanvas API to offload rendering from the main thread. Covers worker-based 2D and WebGL rendering, animation loops inside workers, bitmap transfer, double buffering, chart rendering pipelines, image processing, and performance measurement strategies.
Advanced Web Workers for High Performance JS
Master Web Workers for truly parallel JavaScript execution. Covers dedicated and shared workers, structured cloning, transferable objects, SharedArrayBuffer with Atomics, worker pools, task scheduling, Comlink RPC patterns, module workers, and performance profiling strategies.
JavaScript Macros and Abstract Code Generation
Master JavaScript code generation techniques for compile-time and runtime metaprogramming. Covers AST manipulation, Babel plugin authorship, tagged template literals as macros, code generation pipelines, source-to-source transformation, compile-time evaluation, and safe eval alternatives.