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

💡 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/removals

3️⃣ 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

🎯 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

GoalCode
Createnew MutationObserver(cb)
Watch childrenobserve(n, { childList: true })
Watch attributes{ attributes: true }
Whole subtree{ subtree: true }
Drain queueobserver.takeRecords()
Stopobserver.disconnect()

📚 Keep Learning

🎉 Lesson Complete — MutationObserver!

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

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.