Observing DOM Changes Efficiently with MutationObserver

Watch dynamic pages from an MV3 content script without slowing them down: scoping MutationObserver targets and options, filtering records cheaply, batching with requestIdleCallback, and disconnecting on teardown.

Published October 2, 2026 Updated October 2, 2026 7 min read
Table of Contents

A content script that enhances content — adding a button to every tweet, highlighting prices as they load, annotating links in an infinite feed — has to notice when the page adds new elements. MutationObserver is the right tool, and the naive use of it is one of the most common causes of extensions slowing pages down: observing document.body with subtree: true, childList: true, attributes: true, characterData: true, then running querySelectorAll over the whole document on every callback. On a busy single-page app, that is thousands of callbacks a minute, each scanning thousands of elements. This guide shows how to observe precisely and cheaply. It belongs to content scripts and DOM injection.

What makes an observer expensive

An observer’s cost has three parts. The browser’s cost of recording mutations grows with the options: attributes and characterData on a whole subtree record far more than childList alone, and attributeOldValue adds more. The cost of delivering records is one callback per microtask checkpoint with every record batched — often hundreds of records per callback on a framework re-render. And the cost of your callback is usually the largest: re-scanning the document, reading layout (getBoundingClientRect, offsetHeight) which forces style recalculation, or writing to the DOM, which can trigger further mutations and further callbacks. Efficient observation narrows what is recorded, processes only the nodes that were added, defers non-urgent work, and never reads layout inside the callback.

Callback time per minute on an infinite-scroll feedIllustrative total time spent in a content script's mutation callback during one minute of scrolling, for four observer strategies from naive full-document rescans to targeted, batched processing of added nodes.Body, all options, full rescan4200 ms per m…Body, childList, full rescan1900 ms per m…Feed container, added nodes only310 ms per mi…+ idle batching120 ms per mi…
The biggest wins come from processing only added nodes and deferring work.

Step-by-step: a cheap, precise observer

1. Observe the smallest stable container

1// content.js
2function findFeed() {
3  return document.querySelector('[role="feed"], main [data-testid="timeline"]') ?? document.body;
4}
5
6let feed = findFeed();
7const observer = new MutationObserver(onMutations);
8observer.observe(feed, { childList: true, subtree: true });

Execution context: the content script in the page’s isolated world, which shares the DOM with the page. Observing the feed container instead of body excludes mutations in headers, sidebars and ad slots, which on many sites change constantly. childList alone is enough to detect new items; add attributes only with an attributeFilter naming the attributes you need. If the container itself can be replaced by the app, see step 6.

2. Process only the added nodes

 1const SELECTOR = "article[data-item-id]";
 2
 3function onMutations(records) {
 4  for (const r of records) {
 5    for (const node of r.addedNodes) {
 6      if (node.nodeType !== Node.ELEMENT_NODE) continue;
 7      if (node.matches(SELECTOR)) enqueue(node);
 8      else if (node.firstElementChild) node.querySelectorAll(SELECTOR).forEach(enqueue);
 9    }
10  }
11}

Execution context: the content script. Records list exactly which nodes were added; matching against those — and searching inside them only when they have children — touches a few elements per callback instead of the whole document. Skipping text and comment nodes early avoids pointless work. Do not process removedNodes unless you hold references that need cleanup.

An efficient mutation pipelineMutation records from a scoped observer are filtered to added element nodes matching a selector, de-duplicated with a WeakSet, queued, and processed in an idle callback with a time budget.Scoped observerfeed, childListFilter recordsadded elements onlyWeakSet checkseen before?queue, then process when idleQueuepending nodesrequestIdleCallbacktimeRemaining()Enhance nodeno layout reads
Record little, filter fast, defer the real work.

3. Avoid processing the same node twice

1const seen = new WeakSet();
2const queue = [];
3
4function enqueue(el) {
5  if (seen.has(el)) return;
6  seen.add(el);
7  queue.push(el);
8  scheduleFlush();
9}

Execution context: the content script. Frameworks move and re-insert nodes, which reports them as added again; a WeakSet remembers what you have processed without preventing garbage collection when the page removes them. Marking processed elements with a data attribute also works but writes to the page’s DOM — which can confuse frameworks and triggers your own observer if you watch attributes.

4. Batch work in idle time

 1let flushScheduled = false;
 2function scheduleFlush() {
 3  if (flushScheduled) return;
 4  flushScheduled = true;
 5  const run = (deadline) => {
 6    while (queue.length && (deadline?.timeRemaining?.() ?? 1) > 2) enhance(queue.shift());
 7    flushScheduled = false;
 8    if (queue.length) scheduleFlush();
 9  };
10  "requestIdleCallback" in window ? requestIdleCallback(run, { timeout: 500 }) : setTimeout(run, 50);
11}

Execution context: the content script. requestIdleCallback runs work when the main thread is free, with a deadline that tells you how much time remains in the idle period; the timeout guarantees it runs within half a second even on a busy page. The page’s own rendering and input handling come first, so scrolling stays smooth. Safari lacks requestIdleCallback in some versions — the setTimeout fallback covers it.

A burst of new feed itemsThe page appends twenty items; the observer delivers one callback with twenty records; the callback filters and queues twenty nodes in microseconds; an idle callback enhances them over two idle periods.Page appObserver callbackIdle processorappend 20 items → 1 callbackfilter + WeakSet → queue 20requestIdleCallbackenhance 12 (bud…enhance 8 (next…
The callback itself stays tiny; enhancement waits for idle time.

5. Never read layout in the callback

1// Slow: forces style + layout for every node, on the mutation path
2// if (node.getBoundingClientRect().height > 200) …
3
4// Better: decide with IntersectionObserver, which batches layout work
5const io = new IntersectionObserver((entries) => {
6  for (const e of entries) if (e.isIntersecting) { enhanceVisible(e.target); io.unobserve(e.target); }
7}, { rootMargin: "200px" });
8function enhance(el) { io.observe(el); }

Execution context: the content script. Reading geometry right after the page mutated the DOM forces the browser to recalculate layout synchronously — a “forced reflow” that shows up as long tasks. If enhancement depends on visibility or size, let IntersectionObserver or ResizeObserver report it asynchronously. Enhancing only visible items also cuts work on infinite feeds dramatically.

6. Re-attach when the app replaces the container

 1const rootWatcher = new MutationObserver(() => {
 2  const next = findFeed();
 3  if (next !== feed) {
 4    observer.disconnect();
 5    feed = next;
 6    observer.observe(feed, { childList: true, subtree: true });
 7    feed.querySelectorAll(SELECTOR).forEach(enqueue);           // catch up on existing items
 8  }
 9});
10rootWatcher.observe(document.body, { childList: true });         // shallow: direct children only

Execution context: the content script. Single-page apps often swap the whole main region on navigation, leaving your observer attached to a detached node that never changes again. A shallow observer on body (no subtree) is cheap and notices the swap. Combine with history-change detection as in handling single-page app navigation in content scripts.

7. Disconnect on teardown

1onTeardown(() => { observer.disconnect(); rootWatcher.disconnect(); io.disconnect(); queue.length = 0; });

Execution context: the content script. Observers keep running after an extension update orphans the script, costing CPU for the life of the tab. Register disconnection with the feature’s teardown, as described in cleaning up orphaned content scripts after reload.

Common mistakes

  • Observing body with every option. Recording cost alone can be significant on busy pages.
  • Rescanning the document per callback. Process added nodes only.
  • Writing attributes you also observe. Your own changes trigger your callback in a loop.
  • Layout reads in the callback. Forced reflows cause jank the user blames on the site.
  • Never disconnecting. Orphaned observers run until the tab closes.

Cross-browser variation

  • Chrome / Edge: all APIs above available; DevTools’ Performance panel shows mutation callbacks as tasks attributed to the extension.
  • Firefox: same APIs; content scripts’ DOM access goes through Xray wrappers, which add a small overhead per DOM access — another reason to minimise per-callback work.
  • Safari: requestIdleCallback is missing in some versions; use the fallback. IntersectionObserver is supported.

Verification

  1. Record a Performance trace while scrolling the target page with the extension on and off; compare scripting time attributed to the content script.
  2. Confirm in the console that each new item is enhanced exactly once, even after the app re-renders.
  3. Navigate within the SPA and confirm items on the new view are enhanced.
  4. Reload the extension and confirm the old observer no longer fires.

FAQ

Should I use a selector library that “waits for elements”?

They usually wrap a body-wide observer with full rescans. Fine for a single element at startup; avoid for continuous processing.

Is subtree: true always necessary?

Only if new items can appear below the observed node’s direct children. Observing the immediate parent of feed items without subtree is cheapest.

How many observers is too many?

A handful is fine. One observer with a good dispatcher beats ten overlapping ones on the same subtree.

Other MV3 Architecture & Extension Lifecycle Resources