MutationObserver
Reviewed & published by Brayan K
MutationObserver is a browser API that watches a part of the DOM and calls you back with a list of exactly what changed — nodes added or removed, attributes edited, or text rewritten.
Part of the free JavaScript course at LearnCodingFast — hands-on lessons with examples you run in your browser, plus practice exercises and a quick quiz.
It is how you react to DOM you do not control: a widget injecting elements, content loaded by another script, or attributes flipping as a page updates itself.
What You'll Learn in This Lesson
- Observe added & removed nodes
- Watch attribute and text changes
- Configure observe(target, options)
- Read MutationRecord objects
- Stop with disconnect() and drain with takeRecords()
- Real-world DOM-watching uses
💡 About the runnable demos: The real MutationObserver needs a live DOM, so it stays in the read-only code blocks. Each Try it Yourself runs a plain-JS diff that reports what changed between two snapshots of a tree — the same "here is the list of changes" shape that real mutation records give you.
📹 Real-World Analogy: A MutationObserver is a security camera pointed at one room. You do not stare at the feed all day; the system hands you a highlight reel of everything that moved while you were away — "a box was added here, a label was changed there". You choose what the camera watches (nodes, attributes, text) and review the clips in a batch.
1️⃣ Observing the DOM
Create an observer with a callback, then call observe(target, options) to choose what to watch. The callback receives an array of MutationRecord objects, each describing one change: its type ("childList", "attributes", or "characterData"), the affected node, and lists of addedNodes / removedNodes.
At its heart, this is a diff: compare "before" and "after" and report what was added or removed. Here is that diff running for real:
// "before" and "after" snapshots of a list's child ids
const before = ["item-1", "item-2", "item-3"];
const after = ["item-1", "item-3", "item-4"];
function diffChildren(before, after) {
const beforeSet = new Set(before);
const afterSet = new Set(after);
const addedNodes = after.filter((id) => !beforeSet.has(id));
const removedNodes = before.filter((id) => !afterSet.has(id));
return { type: "childList", addedNodes, removedNodes };
}
const record = diffChildren(before, after);
console.log("added:", record.addedNodes);
// ✅ Expected output: added: [ 'item-4' ]
console.log("removed:", record.removedNodes);
// ✅ Expected output: removed: [ 'item-2' ]2️⃣ Watching Attributes & Text
Turn on attributes: true to be told when an attribute changes; add attributeOldValue: true and each record carries the previous value in oldValue. Turn on characterData: true to catch edits to text node contents. You can also restrict attribute watching to specific names with attributeFilter.
The same "what changed" diff, now over an object's attributes:
const before = { class: "btn", disabled: "false", title: "Save" };
const after = { class: "btn active", disabled: "true", title: "Save" };
function diffAttributes(before, after) {
const records = [];
for (const name of Object.keys(after)) {
if (before[name] !== after[name]) {
records.push({
type: "attributes",
attributeName: name,
oldValue: before[name],
newValue: after[name],
});
}
}
return records;
}
for (const r of diffAttributes(before, after)) {
console.log(r.attributeName + ': "' + r.oldValue + '" -> "' + r.newValue + '"');
}
// ✅ Expected output:
// class: "btn" -> "btn active"
// disabled: "false" -> "true"🎯 Your Turn #1 — Turn On childList
Replace the blank with the option that makes the observer watch added/removed children.
// A model observer that reads which options you enabled
function makeObserver(options) {
return {
report: () => {
if (options.___ ) console.log("watching child additions/removals");
else console.log("not watching children");
},
};
}
// 👉 replace ___ (both above and below) with the right option name
const observer = makeObserver({ ___: true });
observer.report(); // ✅ Expected output: watching child additions/removals3️⃣ disconnect() & takeRecords()
Records are batched and delivered as a microtask after the current script runs, so several quick DOM edits arrive together. Call disconnect() to stop observing entirely. Call takeRecords() to pull out any queued-but-not-yet-delivered records immediately — handy right before disconnecting so nothing is lost.
⚠️ Avoid infinite loops: If your callback edits the same DOM it observes, it can trigger itself forever. Either disconnect() before editing and re-observe after, or guard against the change you just made.
This model batches changes into a queue, then lets you drain it and stop — exactly like the real API:
function makeObserver(callback) {
let queue = [];
let connected = false;
return {
observe() { connected = true; },
record(change) { if (connected) queue.push(change); },
takeRecords() { const r = queue; queue = []; return r; }, // drain
disconnect() { connected = false; queue = []; },
flush() { callback(this.takeRecords()); }, // mimic the microtask delivery
};
}
const obs = makeObserver((records) =>
console.log("delivered:", records.length, "records")
);
obs.observe();
obs.record({ type: "childList" });
obs.record({ type: "attributes" });
// Pull pending records out manually before they auto-deliver
const pending = obs.takeRecords();
console.log("takeRecords got:", pending.length);
// ✅ Expected output: takeRecords got: 2
// Queue is now empty, so a flush delivers nothing
obs.flush();
// ✅ Expected output: delivered: 0 records
obs.disconnect();🎯 Your Turn #2 — Stop Observing
Replace the blank with the method that stops the observer.
function makeObserver() {
let on = true;
return {
record: () => console.log(on ? "recorded" : "ignored — disconnected"),
disconnect: function () { on = false; },
};
}
const obs = makeObserver();
obs.record(); // ✅ Expected output: recorded
// 👉 replace ___ with the method that stops observing
obs.___();
obs.record(); // ✅ Expected output: ignored — disconnected🧠 Quick Recall
Predict the answer before revealing it.
childList: true. Add subtree: true to watch all descendants too.
Pass attributeOldValue: true; the record then has oldValue.
takeRecords() keeps observing (it just drains the queue); disconnect() stops entirely.
Common Errors & Fixes
- 1️⃣ Calling observe() with no options
- 2️⃣ Editing observed DOM inside the callback
- 3️⃣ Expecting attribute records without enabling them
- 4️⃣ Forgetting to disconnect when done
🎯 Mini Challenge — Full Tree Diff
Combine both ideas: write a diffTree that reports added children, removed children, AND changed attributes between two snapshots — a tiny version of what MutationObserver hands you.
const before = {
children: ["a", "b", "c"],
attrs: { class: "list", "data-open": "false" },
};
const after = {
children: ["a", "c", "d"],
attrs: { class: "list active", "data-open": "false" },
};
function diffTree(before, after) {
const records = [];
// childList changes
const had = new Set(before.children);
const has = new Set(after.children);
const added = after.children.filter((c) => !had.has(c));
const removed = before.children.filter((c) => !has.has(c));
if (added.length || removed.length) {
records.push({ type: "childList", added, removed });
}
// attribute changes
for (const name of Object.keys(after.attrs)) {
if (before.attrs[name] !== after.attrs[name]) {
records.push({ type: "attributes", name, oldValue: before.attrs[name] });
}
}
return records;
}
console.log(diffTree(before, after));
// ✅ Expected output:
// [
// { type: 'childList', added: [ 'd' ], removed: [ 'b' ] },
// { type: 'attributes', name: 'class', oldValue: 'list' }
// ]Quick Reference — MutationObserver
| Goal | Code |
|---|---|
| Create | new MutationObserver(cb) |
| Watch children | observe(n, { childList: true }) |
| Watch attributes | { attributes: true } |
| Whole subtree | { subtree: true } |
| Drain queue | observer.takeRecords() |
| Stop | observer.disconnect() |
📚 Keep Learning
- • MDN — MutationObserver
- • MDN — observe() options
- • MDN — MutationRecord
🎉 Lesson Complete — MutationObserver!
- ✅ It reports DOM changes as a batch of MutationRecord objects
- ✅ Options pick what to watch: childList, attributes, characterData, subtree
- ✅ takeRecords() drains the queue; disconnect() stops observing
- ✅ Great for reacting to DOM injected by code you do not control
Practice quiz
What does a MutationObserver watch?
- Element visibility on scroll
- Network requests
- Changes to the DOM such as nodes added/removed, attributes, and text
- CPU usage
Answer: Changes to the DOM such as nodes added/removed, attributes, and text. A MutationObserver watches a part of the DOM and reports what changed — nodes added/removed, attributes edited, or text rewritten.
Which option watches nodes being added or removed?
- childList: true
- attributes: true
- characterData: true
- subtree: true
Answer: childList: true. childList: true watches direct children being added or removed.
What does adding subtree: true do?
- Watches only the target node
- Disables the observer
- Watches network changes
- Watches the entire descendant tree of the target, not just direct children
Answer: Watches the entire descendant tree of the target, not just direct children. subtree: true extends watching to the whole descendant tree of the target.
How do you capture an attribute's previous value?
- Read attribute.previous
- Pass attributeOldValue: true so each record has oldValue
- Use characterData: true
- It is automatic
Answer: Pass attributeOldValue: true so each record has oldValue. attributeOldValue: true makes each record carry the previous value in oldValue.
What does the callback receive?
- An array of MutationRecord objects
- A single change object
- The target element
- A boolean
Answer: An array of MutationRecord objects. The callback receives an array of MutationRecord objects, each describing one change.
Why are mutation records delivered in a batch?
- To encrypt them
- To slow down the page
- For performance — all mutations in a tick are delivered together via a microtask
- Because the DOM requires it
Answer: For performance — all mutations in a tick are delivered together via a microtask. The browser batches all mutations in a tick and delivers them together as a microtask, avoiding firing the callback hundreds of times.
What does disconnect() do?
- Drains the pending queue but keeps observing
- Stops the observer entirely until you call observe() again
- Deletes the target node
- Adds a new option
Answer: Stops the observer entirely until you call observe() again. disconnect() stops observation completely; you must call observe() again to resume.
What does takeRecords() do?
- Stops observing
- Creates a new observer
- Deletes records permanently from the DOM
- Empties and returns queued-but-not-yet-delivered records without stopping observation
Answer: Empties and returns queued-but-not-yet-delivered records without stopping observation. takeRecords() drains any pending records immediately while observation continues — often called right before disconnect().
What risk exists if your callback edits the same DOM it observes?
- Nothing
- It can trigger itself in an infinite loop
- The page reloads
- It changes the threshold
Answer: It can trigger itself in an infinite loop. Editing the observed DOM inside the callback can re-trigger the observer forever; disconnect first or guard the change.
What is a real-world use of MutationObserver?
- Lazy-loading images on scroll
- Storing data offline
- Detecting when a third-party widget or ad injects elements into the page
- Making network requests
Answer: Detecting when a third-party widget or ad injects elements into the page. It is ideal for reacting to DOM you do not control, such as detecting injected widgets, ads, or content from other scripts.
Continue this course
- Previous: IntersectionObserver (Lazy Load & Infinite Scroll)
- Next: IndexedDB (Client-Side Database) — Store large structured data in the browser with transactions and indexes
- Quick reference: JavaScript cheat sheet
Frequently asked questions
What kinds of DOM changes can a MutationObserver detect?
Three families, switched on by the options you pass to observe(): childList watches nodes being added or removed, attributes watches attribute changes (with attributeOldValue to capture the previous value), and characterData watches text content changes inside text nodes. Add subtree: true and it watches the entire descendant tree of the target, not just its direct children.
Why are mutation records delivered in a batch instead of one at a time?
Performance. The browser collects all mutations that happen in a single tick and delivers them together as an array of MutationRecord objects via a microtask, after the current script finishes. This avoids firing your callback hundreds of times during a busy DOM update and lets you process related changes as one group.
What is the difference between disconnect() and takeRecords()?
disconnect() stops the observer entirely — it watches nothing further until you call observe() again. takeRecords() empties and returns any mutation records that have been queued but not yet delivered to your callback, without stopping observation. You often call takeRecords() right before disconnect() to make sure no pending changes are lost.
What are real uses for MutationObserver?
Reacting to DOM you do not control: detecting when a third-party widget or ad injects elements, auto-enhancing content loaded by another script, keeping a table of contents in sync as sections appear, watching for class changes to mirror a theme, or building browser extensions that respond to a page mutating itself.