Watch DOM changes with MutationObserver

MutationObserver calls your function after elements are added, removed or changed, by any code on the page. You pick what to watch, and the changes arrive in batches.

To detect DOM changes in JavaScript, create a MutationObserver and tell it which element to watch and which kinds of change to report. After a change, the browser calls your function with a list of records: what changed, where, and what was added or removed.

const observer = new MutationObserver((records) => {
  for (const r of records) console.log(r.type, r.target);
});
observer.observe(document.getElementById('box'), { childList: true, subtree: true });

Try it. Tick the options, press the buttons, and watch which changes reach the log.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MutationObserver record log</title>
<style>
  body { margin: 0; padding: 12px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; font-size: 14px; }
  .opts { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-bottom: 8px; }
  .opts label { font: 13px ui-monospace, Consolas, monospace; white-space: nowrap; }
  .btns { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
  button { font: inherit; font-size: 13px; padding: 6px 10px; border: 1px solid #c9cdd4; border-radius: 8px; background: #fff; cursor: pointer; }
  #box { background: #fff; border: 2px solid #c9cdd4; border-radius: 10px; padding: 8px 12px; }
  #box.hot { border-color: #2563eb; }
  #box ul { margin: 4px 0 0; padding-left: 20px; }
  #log {
    margin-top: 10px; height: 190px; overflow: auto; padding: 8px 10px; border-radius: 10px;
    background: #111827; color: #d1d5db; font: 12px/1.5 ui-monospace, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere;
  }
  #log .cb { color: #86efac; }
  #log .err { color: #fca5a5; }
</style>
</head>
<body>
<div class="opts">
  <label><input type="checkbox" id="childList" checked> childList</label>
  <label><input type="checkbox" id="subtree"> subtree</label>
  <label><input type="checkbox" id="attributes" checked> attributes</label>
  <label><input type="checkbox" id="attributeOldValue"> attributeOldValue</label>
  <label><input type="checkbox" id="characterData"> characterData</label>
</div>
<div class="btns">
  <button id="addTop">Add line to box</button>
  <button id="addLi">Add list item</button>
  <button id="removeLi">Remove list item</button>
  <button id="toggleClass">Toggle box class</button>
  <button id="setData">Change item data-n</button>
  <button id="editText">Edit item text</button>
  <button id="clear">Clear log</button>
</div>

<div id="box">
  <b>Watched box</b>
  <ul id="list"><li data-n="1">Item 1</li></ul>
</div>
<div id="log"></div>

<script>
  const box = document.getElementById('box');
  const list = document.getElementById('list');
  const log = document.getElementById('log');
  const names = ['childList', 'subtree', 'attributes', 'attributeOldValue', 'characterData'];
  let calls = 0, n = 1;

  function write(text, cls) {
    const line = document.createElement('div');
    line.textContent = text;
    if (cls) line.className = cls;
    log.append(line);
    log.scrollTop = log.scrollHeight;
  }

  function label(node) {
    if (node.nodeType === Node.TEXT_NODE) return '#text "' + node.data + '"';
    return '<' + node.nodeName.toLowerCase() + (node.id ? '#' + node.id : '') + '>';
  }

  // The callback gets every record queued since the last call
  const observer = new MutationObserver((records) => {
    write('callback #' + (++calls) + ': ' + records.length + ' record(s)', 'cb');
    for (const r of records) {
      let s = '  ' + r.type + ' on ' + label(r.target);
      if (r.addedNodes.length) s += ' | added ' + [...r.addedNodes].map(label).join(', ');
      if (r.removedNodes.length) s += ' | removed ' + [...r.removedNodes].map(label).join(', ');
      if (r.attributeName) s += ' | attributeName: ' + r.attributeName;
      if (r.type !== 'childList') s += ' | oldValue: ' + JSON.stringify(r.oldValue);
      write(s);
    }
  });

  function start() {
    observer.disconnect();
    const opt = {};
    for (const k of names) opt[k] = document.getElementById(k).checked;
    if (!opt.attributes) delete opt.attributeOldValue;  // attributeOldValue needs attributes
    try {
      observer.observe(box, opt);
      write('observe(box, ' + JSON.stringify(opt) + ')', 'cb');
    } catch (err) {
      write(err.name + ': pick childList, attributes or characterData', 'err');
    }
  }
  names.forEach((k) => document.getElementById(k).addEventListener('change', start));

  document.getElementById('addTop').addEventListener('click', () => {
    const p = document.createElement('p');
    p.textContent = 'Line added to the box itself';
    box.append(p);
  });
  document.getElementById('addLi').addEventListener('click', () => {
    const li = document.createElement('li');
    li.textContent = 'Item ' + (++n);
    li.dataset.n = n;
    list.append(li);
  });
  document.getElementById('removeLi').addEventListener('click', () => list.lastElementChild?.remove());
  document.getElementById('toggleClass').addEventListener('click', () => box.classList.toggle('hot'));
  document.getElementById('setData').addEventListener('click', () => {
    const li = list.firstElementChild;
    if (li) li.dataset.n = Number(li.dataset.n) + 10;
  });
  document.getElementById('editText').addEventListener('click', () => {
    const li = list.firstElementChild;
    if (li) li.firstChild.data += '!';  // change the text node itself
  });
  document.getElementById('clear').addEventListener('click', () => { log.textContent = ''; calls = 0; });

  start();
</script>
</body>
</html>
Each button makes one kind of change. With only childList ticked, "Add list item" logs nothing: the list is inside the box, so it needs subtree.

It works no matter who makes the change: your code, another script, a browser extension or the user editing a contenteditable area.

The options: what the observer watches

observe(target, options) needs at least one of childList, attributes or characterData. With none of them, it throws a TypeError. Everything else narrows or widens what those three catch.

Without subtree, only the target is watched. With subtree, every node below it is too.
Without subtree, only the target is watched. With subtree, every node below it is too.
Option What it reports
childList Children added to or removed from the target
subtree Apply the other options to every descendant, not just the target
attributes Attribute changes, including class, style and data-*
attributeFilter An array of names; only those attributes are reported
attributeOldValue Keep the attribute value from before the change
characterData Changes to the text inside text nodes
characterDataOldValue Keep the text from before the change

Setting attributeFilter or attributeOldValue without attributes turns attributes on for you. The same goes for characterDataOldValue and characterData. Watching only what you need also keeps the callback quiet.

// Only react when data-state changes, anywhere inside #app
observer.observe(app, { subtree: true, attributeFilter: ['data-state'], attributeOldValue: true });

Reading a MutationRecord

Each record describes one change. Start with type, because it decides which other fields mean anything.

  • type is 'childList', 'attributes' or 'characterData'.
  • target is the node that changed. For childList it is the parent; for text it is the text node itself.
  • addedNodes / removedNodes are node lists, filled only for childList. They can contain text nodes, so check nodeType before calling element methods.
  • attributeName is the name of the changed attribute.
  • oldValue is the previous attribute value or text, only if you asked for it. Otherwise it is null.

Toggling a class in the demo gives an attributes record with attributeName: class. If you change classes with classList, this is how another part of the page can react to it.

Callbacks are batched

The callback does not run in the middle of your code. Records queue up while your code runs, then the callback runs once, in a microtask, with all of them. That is why the log in the next demo prints "handler finished" before "callback #1".

Five appends in one click handler give one callback with five records.
Five appends in one click handler give one callback with five records.

If you need the pending records right now, observer.takeRecords() returns them and empties the queue, so the callback will not see them again. A common use is just before disconnect(), to handle the last changes yourself.

The infinite-loop trap

If the callback changes something it is watching, that change creates a new record, which calls the callback again, which changes it again. Each round is a new microtask, so the page stops responding.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MutationObserver batching and the loop trap</title>
<style>
  body { margin: 0; padding: 12px; font-family: system-ui, sans-serif; background: #f4f5f7; color: #1d2330; font-size: 14px; }
  .modes { display: flex; flex-wrap: wrap; gap: 4px 14px; margin-bottom: 8px; font-size: 13px; }
  .btns { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 10px; }
  button { font: inherit; font-size: 13px; padding: 6px 10px; border: 1px solid #c9cdd4; border-radius: 8px; background: #fff; cursor: pointer; }
  #area { background: #fff; border: 1px solid #c9cdd4; border-radius: 10px; padding: 8px 12px; }
  #badge { display: inline-block; min-width: 22px; text-align: center; padding: 1px 7px; border-radius: 99px; background: #2563eb; color: #fff; font-size: 12px; }
  #list { margin: 6px 0 0; padding-left: 20px; max-height: 70px; overflow: auto; }
  #log {
    margin-top: 10px; height: 200px; overflow: auto; padding: 8px 10px; border-radius: 10px;
    background: #111827; color: #d1d5db; font: 12px/1.5 ui-monospace, Consolas, monospace; white-space: pre-wrap; overflow-wrap: anywhere;
  }
  #log .bad { color: #fdba74; }
  #log .good { color: #86efac; }
</style>
</head>
<body>
<div class="modes">
  <b>Callback updates the badge:</b>
  <label><input type="radio" name="mode" value="naive" checked> naive</label>
  <label><input type="radio" name="mode" value="guard"> guard</label>
  <label><input type="radio" name="mode" value="pause"> disconnect / reconnect</label>
</div>
<div class="btns">
  <button id="add1">Add 1 item</button>
  <button id="add5">Add 5 items in one click</button>
  <button id="clear">Clear log</button>
</div>

<div id="area">
  Items: <span id="badge">0</span>
  <ul id="list"></ul>
</div>
<div id="log"></div>

<script>
  const area = document.getElementById('area');
  const list = document.getElementById('list');
  const badge = document.getElementById('badge');
  const log = document.getElementById('log');
  const opts = { childList: true, subtree: true };  // the badge is inside the watched area
  let calls = 0, count = 0;

  function write(text, cls) {
    const line = document.createElement('div');
    line.textContent = text;
    if (cls) line.className = cls;
    log.append(line);
    log.scrollTop = log.scrollHeight;
  }

  const observer = new MutationObserver((records) => {
    calls++;
    write('callback #' + calls + ': ' + records.length + ' record(s)');
    if (calls >= 20) {  // demo safety stop, so the page does not hang
      observer.disconnect();
      write('Stopped after 20 callbacks from one click. The callback keeps triggering itself.', 'bad');
      return;
    }

    const mode = document.querySelector('input[name=mode]:checked').value;
    const n = String(list.children.length);

    if (mode === 'naive') {
      badge.textContent = n;                 // a new text node every time: another record
    } else if (mode === 'guard') {
      if (badge.textContent !== n) badge.textContent = n;  // write only when it changes
    } else {
      observer.disconnect();                 // stop watching
      badge.textContent = n;                 // this change is not recorded
      observer.observe(area, opts);          // watch again
    }
  });
  observer.observe(area, opts);

  function add(k) {
    calls = 0;
    observer.observe(area, opts);  // re-arm after a safety stop (same options, no harm)
    write('click: adding ' + k + ' item(s)', 'good');
    for (let i = 0; i < k; i++) {
      const li = document.createElement('li');
      li.textContent = 'Item ' + (++count);
      list.append(li);                        // each append queues one record
    }
    write('click handler finished; callback has not run yet', 'good');
  }

  document.getElementById('add1').addEventListener('click', () => add(1));
  document.getElementById('add5').addEventListener('click', () => add(5));
  document.getElementById('clear').addEventListener('click', () => { log.textContent = ''; });
</script>
</body>
</html>
Pick a mode and add items. Naive keeps triggering itself until the demo's safety stop at 20 callbacks. Guard ends after two; disconnect / reconnect after one.

Setting textContent always replaces the text node, even with the same text, and setAttribute records a change even when the value is identical. So "it is the same value" does not stop the loop. Two fixes work:

Either write only when the value differs, or stop observing while you write.
Either write only when the value differs, or stop observing while you write.
  1. Guard: compare before writing, and skip the write if nothing would change.
  2. Pause: call disconnect(), make your change, then call observe() again with the same options. Changes made in between are never recorded.

A third option is to filter records: ignore those whose target is an element you own, such as a badge or counter. That works too, but the callback still runs once more per write.

A contenteditable area is where an observer earns its place. Typing inside a line changes a text node (characterData), while Enter, paste and delete add or remove nodes (childList). Exactly which nodes change can differ between browsers, so watch both, with subtree.

Live exampletry it here, then copy the code
Share it as a link
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Word counter and auto-link with MutationObserver</title>
<style>
  body { margin: 0; padding: 12px; font-family: system-ui, sans-serif; background: #eceef1; color: #1d2330; }
  .bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 14px; margin-bottom: 8px; font-size: 13px; }
  .stat b { font-size: 16px; }
  button { font: inherit; font-size: 13px; padding: 6px 10px; border: 1px solid #c9cdd4; border-radius: 8px; background: #fff; cursor: pointer; }
  #editor {
    min-height: 200px; padding: 12px 14px; border-radius: 10px; background: #fff;
    border: 1px solid #c9cdd4; font-size: 15px; line-height: 1.55; outline: none;
  }
  #editor:focus { border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37, 99, 235, .18); }
  #editor a { color: #1d4ed8; background: #e0ecff; border-radius: 4px; padding: 0 2px; }
  #status { margin-top: 8px; font: 12px ui-monospace, Consolas, monospace; color: #5b6270; }
</style>
</head>
<body>
<div class="bar">
  <span class="stat"><b id="words">0</b> words</span>
  <span class="stat"><b id="chars">0</b> characters</span>
  <span class="stat"><b id="links">0</b> links</span>
  <button id="inject">Insert text from another script</button>
</div>

<div id="editor" contenteditable="true"><p>Type here. Links turn blue after the space: https://example.com is one.</p></div>
<div id="status">waiting for changes</div>

<script>
  const editor = document.getElementById('editor');
  const opts = { childList: true, subtree: true, characterData: true };
  const URL_DONE = /https?:\/\/[^\s<]+(?=\s)/;  // a URL followed by a space: finished typing

  // Wrap the first finished URL in each plain text node. Returns true if it changed anything.
  function linkify() {
    const walker = document.createTreeWalker(editor, NodeFilter.SHOW_TEXT);
    const found = [];
    while (walker.nextNode()) {
      const t = walker.currentNode;
      if (!t.parentElement.closest('a') && URL_DONE.test(t.data)) found.push(t);
    }
    const sel = getSelection();
    for (const t of found) {
      const m = t.data.match(URL_DONE);
      const caretHere = sel.anchorNode === t ? sel.anchorOffset : -1;
      const urlNode = t.splitText(m.index);           // text before stays in t
      const rest = urlNode.splitText(m[0].length);    // text after the URL
      const a = document.createElement('a');
      a.href = m[0];
      a.title = m[0];
      urlNode.replaceWith(a);
      a.append(urlNode);
      // keep the caret where the user was typing
      if (caretHere >= m.index + m[0].length) sel.collapse(rest, caretHere - m.index - m[0].length);
    }
    return found.length > 0;
  }

  function count() {
    const text = editor.innerText.trim();
    document.getElementById('words').textContent = text ? text.split(/\s+/).length : 0;
    document.getElementById('chars').textContent = text.length;
    document.getElementById('links').textContent = editor.querySelectorAll('a').length;
  }

  const observer = new MutationObserver((records) => {
    observer.disconnect();              // our own edits below must not trigger the callback
    const linked = linkify();
    observer.observe(editor, opts);
    count();
    const types = [...new Set(records.map((r) => r.type))].join(', ');
    document.getElementById('status').textContent =
      records.length + ' record(s): ' + types + (linked ? ' | link added' : '');
  });
  observer.observe(editor, opts);

  // Any code can change the editor. The observer sees it the same way as typing.
  document.getElementById('inject').addEventListener('click', () => {
    const p = document.createElement('p');
    p.textContent = 'Added by another script. Docs at https://developer.mozilla.org and more.';
    editor.append(p);
  });

  linkify();  // enhance what is already there
  count();
</script>
</body>
</html>
Type a URL and a space and it becomes a link. The button inserts text from separate code, and the observer handles it the same way.
  • Counting: the counters live outside the editor, so updating them never triggers the observer.
  • Auto-link: wrapping a URL in <a> changes the editor, so the callback disconnects, edits, and observes again.
  • Enhancing new elements: the "another script" button shows the other common use. Whatever adds content, the observer upgrades it.

MutationObserver vs polling and mutation events

Approach How it notices Cost and caveats
Polling with setInterval Compares the DOM every few hundred ms Runs even when nothing changed; misses fast back-and-forth changes
Mutation events (DOMNodeInserted, DOMSubtreeModified) Fired synchronously on every change Deprecated, and some browsers have removed them
MutationObserver Records every change, delivers them in one batch Needs the right options; you must avoid re-triggering

For size or visibility changes, use the observers built for those. ResizeObserver reports size changes, and IntersectionObserver tells you when an element enters the viewport. MutationObserver reports changes to the DOM tree itself, as described in the DOM guide.

When it does not work

What you see Cause Fix
Nothing is logged The change is inside a child, and subtree is off; or attributes/characterData is not set Add the missing option
TypeError when calling observe None of childList, attributes or characterData is true Turn at least one on
The callback floods or the page freezes The callback changes what it watches Guard the write, or disconnect and observe again
oldValue is null attributeOldValue or characterDataOldValue is off Turn on the matching OldValue option
TypeError saying the target is not a Node The script ran before the element existed Run it after the element, use defer, or observe a parent that already exists
Callbacks keep coming after a component closes The observer was never stopped Call observer.disconnect() when you are done

When you cannot wait for an element, observe document.body with childList and subtree, check addedNodes for the element, and disconnect once it appears.

An observer is easier to understand by pressing the buttons than by reading the log in a screenshot. To send a working page instead, paste it into a NOS document and choose Create share link. HTML to link walks through it.

The page renders as written and its scripts run, so the person you send it to can try it themselves. If you change the code later, the same link shows the new version.

Questions people ask

How do I detect DOM changes in JavaScript?

Create a MutationObserver with a callback, then call observe(element, options) with at least one of childList, attributes or characterData set to true. Add subtree: true to include every element inside. The callback receives an array of MutationRecord objects describing each change.

Why is oldValue null in my mutation records?

The browser only keeps the previous value when you ask for it. Set attributeOldValue: true for attribute changes and characterDataOldValue: true for text changes. For childList records, oldValue is always null.

Does MutationObserver fire on every single change?

No. Records are collected while your code runs, and the callback runs once afterwards, in a microtask, with all of them. Five appends in one click handler give one callback with five records.

Can MutationObserver see a user typing in an input field?

No. Typing changes the value property of an input or textarea, not an attribute or a node, so no record is created. Listen for the input event there. In a contenteditable element, typing does change nodes, so an observer sees it.

Should I still use DOMNodeInserted or DOMSubtreeModified?

No. Those mutation events are deprecated and some browsers have already removed them. They fired synchronously on every change. MutationObserver replaces them.

Keep reading