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.
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
// 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:
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:
// 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.
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.lengthduring 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.
Common Mistakes to Avoid
- Using
MutationObserverfor anything positional. It cannot see position, size or visibility. - Using
IntersectionObserverto 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.bodywithsubtree: 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.
Related
- Detecting Added and Removed Nodes with MutationObserver — why addedNodes needs a subtree walk
- MutationObserver DOM Change Tracking — the init flags and what each one costs
- Lazy Loading Images & Media — the intersection half of the handoff
↑ Back to MutationObserver DOM Change Tracking