The two APIs look similar enough — a constructor, a callback, a batch of entries — that they get chosen by familiarity rather than by fit. They answer questions that do not overlap at all, and using the wrong one produces code that works in development and fails on real content.

Problem / Scenario Context

A team needs to lazily load images inside content rendered by a rich-text editor. Because the content arrives asynchronously and the images do not exist at page load, they reach for MutationObserver — it watches for new nodes, so it is clearly the right tool for "images that appear later".

The result loads every image the moment it is inserted into the document, whether or not it is anywhere near the viewport. The feature is doing exactly what was asked and nothing that was wanted, because the question "has this element appeared in the DOM" is not the question "is this element on screen".

The inverse error is just as common: using IntersectionObserver to detect that content has finished rendering, which fails silently for anything inserted below the fold.

Mechanics Explanation

The distinction is one sentence: MutationObserver watches the document tree; IntersectionObserver watches geometry.

MutationObserver fires when nodes are added or removed, attributes change, or text changes. It knows nothing about position, size or visibility, and it fires for elements that are hidden, detached from the viewport, or inside a collapsed panel. Its callbacks are delivered as a microtask after the mutating script finishes.

IntersectionObserver fires when an element's overlap with a root crosses a threshold. It knows nothing about how or when the element got there. Its callbacks are delivered from the rendering pipeline, after layout.

Cost differs accordingly. A MutationObserver with subtree: true on a busy container is genuinely expensive, because every insertion anywhere beneath it queues a record. An IntersectionObserver computes during a layout pass the browser was performing anyway.

Two Questions That Do Not OverlapTwo columns of questions. MutationObserver answers whether a node was added or removed, whether an attribute changed and whether text changed, and cannot answer anything about position. IntersectionObserver answers whether an element is on screen, how much of it is showing and when it crossed a threshold, and cannot answer anything about the tree. A joining note explains the pattern where both are needed.MutationObserverWas a node added or removed?Did an attribute change?Did text content change?Knows nothing about position or sizeIntersectionObserverIs it on screen?How much of it is showing?When did it cross a threshold?Knows nothing about how it got thereTogether: mutation finds the new element, intersection decides when to act on itThis is the correct shape for lazy loading inside dynamically rendered content.

Comparison Table: Choosing by Question

What you need to know Use
An element scrolled into view IntersectionObserver
An element was inserted by a third-party script MutationObserver
A card is half visible for one second IntersectionObserver
An attribute was flipped by code you do not control MutationObserver
A sticky header should pin IntersectionObserver with a sentinel
Rich-text content finished rendering MutationObserver
A lazily inserted image should load when reached both — mutation to find it, intersection to load it
An element changed size neither — ResizeObserver

The last row is worth its place because it is the third common mis-selection: watching for style attribute mutations to detect a resize, which misses every resize caused by a stylesheet, a flex sibling or a font load.

Minimal Reproducible Example

TypeScript
// Wrong tool: loads everything the moment it is inserted
new MutationObserver((records) => {
  for (const r of records) {
    for (const node of r.addedNodes) {
      if (node instanceof HTMLImageElement && node.dataset.src) {
        node.src = node.dataset.src;        // no idea whether it is on screen
      }
    }
  }
}).observe(container, { childList: true, subtree: true });

Production-Safe Solution

Use each for what it knows, and hand off between them:

TypeScript
const lazyImages = new IntersectionObserver((entries, obs) => {
  for (const entry of entries) {
    if (!entry.isIntersecting) continue;
    const img = entry.target as HTMLImageElement;
    if (img.dataset.src) { img.src = img.dataset.src; delete img.dataset.src; }
    obs.unobserve(img);                     // one-shot
  }
}, { rootMargin: '200px 0px' });

/** Register anything in a freshly inserted subtree — including the node itself. */
function registerLazy(root: ParentNode | Element): void {
  if (root instanceof HTMLImageElement && root.dataset.src) lazyImages.observe(root);
  root.querySelectorAll?.('img[data-src]').forEach((img) => lazyImages.observe(img));
}

registerLazy(container);                    // whatever is already there

new MutationObserver((records) => {
  for (const record of records) {
    for (const node of record.addedNodes) {
      if (node.nodeType === Node.ELEMENT_NODE) registerLazy(node as Element);
    }
  }
}).observe(container, { childList: true, subtree: true });

Both branches of registerLazy are needed, for the reason covered in detecting added and removed nodes: addedNodes reports the root of an insertion, not every descendant, so an inserted <figure> containing an image never appears as an image in any record.

Two cost controls are worth applying to the mutation half, since it is the expensive one:

TypeScript
// Scope as tightly as the markup allows — not document.body
mo.observe(container, { childList: true, subtree: true });

// And nothing else: no attributes, no characterData
// Each additional flag multiplies the record volume on a busy container.

If the content source can tell you when it has finished rendering — most editors and frameworks emit an event — prefer that over a MutationObserver entirely. The observer is the fallback for content you do not control, not the default for content you do.

Mutation Finds It, Intersection Acts on ItA pipeline in four steps. Content is inserted by an editor. A MutationObserver detects the inserted subtree and walks it for matching elements. Each match is registered with a shared IntersectionObserver. Nothing loads until the reader scrolls it into view, at which point the intersection callback performs the swap and unobserves.editor insertsa <figure> subtreeMutationObserverwalks the subtreeobserve(img)registered, not loadedon screenswap + unobserveNeither observer can do this alone — and neither is doing the other's jobScope the mutation observer to the content container, with childList and subtree only. Every extra flag multipliesrecord volume on a container that is being actively edited.

Verification Steps

  • Confirm nothing loads on insertion: insert content below the fold and check the network panel stays quiet.
  • Confirm nested matches are found by inserting a wrapper element containing the target rather than the target itself.
  • Measure the mutation cost by logging records.length during a heavy edit; a container producing hundreds of records per keystroke is scoped too broadly.
  • Check for an event you could use instead — if the content source emits one, the mutation observer is unnecessary complexity.

Cost Profiles Side by SideTwo cost curves across a busy page. A subtree MutationObserver on an actively edited container queues a record per insertion, so its cost rises with edit volume. An IntersectionObserver computes during the layout pass the browser already runs, so its cost stays close to flat regardless of how many elements it watches.Cost against DOM activity, for the same pageMutationObserver, subtreeIntersectionObserverDOM activity →Which is why scoping the mutation observer tightly matters far more than scoping the other one.

Common Mistakes to Avoid

  • Using MutationObserver for anything positional. It cannot see position, size or visibility.
  • Using IntersectionObserver to detect that content rendered. It reports nothing for elements inserted below the fold until they are scrolled to.
  • Watching style attribute mutations to detect resizes. Misses every resize that does not come from an inline style — which is most of them. Use ResizeObserver.
  • Observing document.body with subtree: true. The record volume on a real application is enormous, and almost all of it is irrelevant.

FAQ

Which one should I use for lazy loading dynamically inserted images?

Both, in sequence. A MutationObserver finds elements as they are inserted and registers them; a shared IntersectionObserver decides when each one is close enough to load. Using mutation alone loads everything on insertion, and using intersection alone never sees elements that did not exist when you registered them.

Can MutationObserver tell me when an element became visible?

No. It reports changes to the document tree — nodes, attributes and text — and has no knowledge of layout, position or visibility. An element inserted inside a collapsed panel or far below the fold produces exactly the same record as one inserted in front of the reader.

Is MutationObserver expensive?

It can be. A subtree observation on a busy container queues a record for every insertion beneath it, and observing document.body on a real application produces an enormous volume. Scope it as tightly as the markup allows and enable only the flags you actually consume.

What about detecting that an element changed size?

Neither of these. Watching for style attribute mutations misses every resize caused by a stylesheet, a flex sibling or a font finishing loading, which is most of them. ResizeObserver is the API that reports box changes regardless of what caused them.


↑ Back to MutationObserver DOM Change Tracking