← blog
JavaScriptAugust 11, 2026 · 4 min read

You're polling setInterval to detect DOM changes. MutationObserver fires when they happen.

Watching for DOM mutations with setInterval or custom event dispatch is fragile and imprecise. The MutationObserver API delivers a callback exactly when attributes, text content, or child elements change — with full control over which types of mutations you watch.

Parsa Jiravand · Frontend engineer · building bestpractic
You're polling setInterval to detect DOM changes. `MutationObserver` fires when they happen.

The DOM can change in ways your code didn't trigger — a third-party widget injects a node, a library adds a class, a rich text editor updates its content. The common response is polling: setInterval(() => checkIfThingChanged(), 100). It works until it doesn't. You tune the interval, miss changes that happen between ticks, and pay CPU cost whether anything changes or not. MutationObserver is the built-in answer.

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { console.log(mutation.type); // 'attributes' | 'characterData' | 'childList' console.log(mutation.target); // the node that changed console.log(mutation.addedNodes); // NodeList (for childList mutations) console.log(mutation.oldValue); // previous value (if requested) } }); observer.observe(document.getElementById('app'), { childList: true, // watch for added/removed child nodes subtree: true, // include all descendants, not just direct children attributes: true, // watch attribute changes characterData: true, // watch text node content changes attributeOldValue: true, // record the previous attribute value characterDataOldValue: true, // record the previous text content }); // Stop watching: observer.disconnect();

Two steps: create an observer with a callback, then call .observe(node, options). The callback fires asynchronously after a batch of mutations completes — never mid-update — with an array of MutationRecord objects, one per change.

Here's the typical "wait for a class to appear" pattern using an interval:

JavaScript
1
2
3
4
5
6
7
8
9
10
// Before — fragile, always running let seen = false; const timer = setInterval(() => { const el = document.querySelector('.widget-loaded'); if (el && !seen) { seen = true; clearInterval(timer); initWidget(el); } }, 50);

The 50 ms interval means you catch changes up to 50 ms late, it burns CPU on every tick even when nothing is happening, and if you forget to clearInterval, it runs indefinitely. With MutationObserver:

JavaScript
1
2
3
4
5
6
7
8
9
10
// After — fires immediately when the node appears const observer = new MutationObserver(() => { const el = document.querySelector('.widget-loaded'); if (el) { observer.disconnect(); initWidget(el); } }); observer.observe(document.body, { childList: true, subtree: true });

No timer, no polling overhead, no missed windows.

Third-party widget lifecycle. When you embed a chat widget or payment form from a third party, you have no hook into when it finishes rendering. MutationObserver on the container gives you that hook without touching the third-party script.

Auto-resizing a textarea. The input event doesn't fire on programmatic changes. Observing characterData on the textarea's text node catches every content change regardless of source:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
14
function autoResize(textarea) { const resize = () => { textarea.style.height = 'auto'; textarea.style.height = `${textarea.scrollHeight}px`; }; resize(); const observer = new MutationObserver(resize); observer.observe(textarea, { characterData: true, subtree: true }); textarea.addEventListener('input', resize); return () => observer.disconnect(); }

Accessible live announcements. Screen readers miss DOM updates that don't go through an aria-live region. A MutationObserver on a dynamic feed can forward new content to an announcer element, making additions audible without a framework:

JavaScript
1
2
3
4
5
6
7
8
9
10
11
12
13
const announcer = document.getElementById('sr-announcer'); // aria-live="polite" const observer = new MutationObserver((mutations) => { for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node.textContent?.trim()) { announcer.textContent = node.textContent.trim(); } } } }); observer.observe(document.getElementById('feed'), { childList: true });

MutationObserver delivers changes in batches. If a loop adds 500 nodes in one synchronous operation, you get one callback with 500 records — not 500 callbacks. This is intentional: the observer is low-overhead even on large, active DOM trees.

A few things to keep in mind:

  • Narrow the target. Observing document.body with subtree: true watches every node in the page. Prefer the smallest relevant container.
  • Always disconnect. The observer holds a reference to the target node, which can delay garbage collection. Call observer.disconnect() when you're done.
  • takeRecords() for early cleanup. This flushes any pending, undelivered records synchronously — useful when you need to process remaining mutations before disconnecting without waiting for the next callback.

MutationObserver is Baseline 2015: Chrome 26 (2013), Firefox 14 (2012), Safari 7 (2013). It has shipped in every major browser and runtime for over a decade, including Web Workers.

Runs right in your browser — poke at it and watch the concept react live.

Instant feedback, a hint on every question, and an explanation for each answer — right or wrong.

Search your codebase for setInterval calls that check a DOM condition and clear themselves once it's true — that's polling for a DOM change. MutationObserver replaces the pattern with a callback that fires the moment the change occurs, with zero cost in between. It completes the browser's built-in observer trio: IntersectionObserver for element visibility, ResizeObserver for element size, and MutationObserver for everything the DOM itself changes. Once you know all three, most "watch the DOM" problems reduce to picking the right one.


Thanks for reading! Let's stay connected:

Originally published on dev.to