Area: Watching the DOM
Related: Browser APIs (map), Page Lifecycle
What are they?
The browser has three “observer” APIs. Each one calls you back when something about elements changes, so you don’t have to poll.
| Observer | Tells you when… |
|---|---|
| ResizeObserver | an element’s size changes |
| MutationObserver | the DOM tree changes: nodes added or removed, attributes changed, text changed |
| IntersectionObserver | an element enters or leaves the viewport (or another scroll box) |
They all work the same way: create an observer with a callback, call observe(element), and later disconnect().
Instead of asking “has it changed yet?” sixty times a second, you tell the browser “let me know when it changes”. The browser already knows, because it’s the one changing it.
ResizeObserver
The problem it solves
window.onresize only tells you the window changed size. Elements change size for many other reasons: a sidebar opens, a panel is dragged wider, an image loads, text wraps differently, the content of an editor grows. Before ResizeObserver, people polled offsetWidth on a timer or in requestAnimationFrame, which wastes work and forces layout.
Basic use
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
const { inlineSize, blockSize } = entry.contentBoxSize[0];
console.log(entry.target, inlineSize, blockSize);
}
});
observer.observe(panel);
// later
observer.unobserve(panel);
observer.disconnect();inlineSize is width and blockSize is height in normal left-to-right text. They’re named that way so they also make sense in vertical writing modes.
Which box?
observer.observe(el, { box: "content-box" }); // default: inside the padding
observer.observe(el, { box: "border-box" }); // including padding and border
observer.observe(el, { box: "device-pixel-content-box" }); // in real screen pixels, for canvasEach entry has contentBoxSize, borderBoxSize and (if asked for) devicePixelContentBoxSize. They’re arrays because one element can be split into several fragments (for example across CSS columns). Usually you read [0].
The older entry.contentRect still works and is widely used, but the box-size fields are more precise.
When the callback runs
After layout and before paint, in the same frame. So if you change styles in response, the user never sees the in-between state. That’s why ResizeObserver is good for things like “make this canvas match its container” or “switch to a compact layout under 400 px”.
It also fires once right after observe(), with the current size. You don’t need a separate “measure on mount” step.
The loop error
You’ll eventually see:
ResizeObserver loop completed with undelivered notifications.
It happens when your callback changes the size of something being observed, which triggers another resize in the same frame. The browser runs a few rounds, then gives up for this frame and reports that error. The pending notifications are delivered in the next frame.
What it usually means: harmless in most apps, but it is a sign you’ve built a feedback loop. Common causes and fixes:
- You set the element’s own height based on its height. Don’t, or only change it when the value actually differs.
- Two elements resize each other. Break the loop.
- Defer the change to the next frame with
requestAnimationFrameif it doesn’t need to happen in the same frame.
Error-tracking tools like Sentry often get flooded with this message. Many teams filter it.
Performance
- Use one observer for many elements, not one observer per element. The callback gets every changed entry in one batch.
- Don’t read layout from other elements inside the callback if you can avoid it. The size you need is already in the entry.
- Always
disconnect()in cleanup (useEffectreturn). An observer holding an element keeps it alive in memory.
React example
function useElementWidth<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [width, setWidth] = useState(0);
useEffect(() => {
const el = ref.current;
if (!el) return;
const observer = new ResizeObserver(([entry]) => {
const next = Math.round(entry.contentBoxSize[0].inlineSize);
setWidth((prev) => (prev === next ? prev : next)); // no re-render if unchanged
});
observer.observe(el);
return () => observer.disconnect();
}, []);
return [ref, width] as const;
}Rounding and the “same value” check stop sub-pixel jitter from causing endless re-renders.
ResizeObserver vs container queries
CSS container queries (@container (min-width: 400px)) now let CSS react to a container’s size directly. If all you need is a different style at a certain width, use container queries: no JavaScript, no loop risk. Use ResizeObserver when JavaScript needs the number, for example to size a canvas, position a floating element, or decide how many items to render.
MutationObserver
The problem it solves
Sometimes you need to know when the DOM changes and you’re not the one changing it: a third-party widget, a rich-text editor, a browser extension, content loaded by someone else’s script.
The old way was “mutation events” (DOMNodeInserted and friends). They fired synchronously on every single change and were terribly slow. They’ve been removed from browsers. MutationObserver replaced them.
Basic use
const observer = new MutationObserver((records) => {
for (const record of records) {
if (record.type === "childList") {
record.addedNodes.forEach((node) => console.log("added", node));
record.removedNodes.forEach((node) => console.log("removed", node));
} else if (record.type === "attributes") {
console.log(record.attributeName, "was", record.oldValue);
} else if (record.type === "characterData") {
console.log("text changed");
}
}
});
observer.observe(container, {
childList: true, // children added or removed
subtree: true, // ...anywhere below, not just direct children
attributes: true, // attribute changes
attributeFilter: ["class", "data-state"], // only these attributes
attributeOldValue: true,
characterData: true, // text node content changes
});You must turn on at least one of childList, attributes or characterData, or observe throws.
When the callback runs
Batched, as a microtask. If your code adds 500 nodes in a loop, you get one callback with all 500 records, right after your code finishes and before the browser renders. That batching is the big improvement over the old mutation events.
observer.takeRecords() hands you the records queued so far without waiting for the callback. Useful right before disconnect() so you don’t lose the last batch.
The feedback loop
If your callback changes the DOM it’s watching, it triggers itself:
// ❌ infinite loop
new MutationObserver(() => {
container.appendChild(document.createElement("span"));
}).observe(container, { childList: true });Fixes:
- Disconnect, make your change, then observe again.
- Or check whether the change is one you made (a flag, or a marker attribute) and skip it.
- Or watch a narrower part of the tree.
Performance
subtree: trueondocument.bodywithcharacterDataon means a callback for every keystroke anywhere on the page. Keep the target and options as narrow as possible.- Use
attributeFilter. Watching every attribute on a busy tree is expensive. - Do cheap checks in the callback and push heavy work to later (
requestAnimationFrameorrequestIdleCallback).
Watching an editor’s DOM
Rich-text editors like ProseMirror (and Tiptap, built on it) own their DOM. They watch it themselves with a MutationObserver and treat unexpected changes as user input. Two rules if you observe one:
- Read only. If you change the editor’s DOM from outside, the editor will either undo your change or think the user typed it.
- To add things inside the editor, use the editor’s own tools (decorations, node views, plugins), not raw DOM edits.
Observing the editor from outside is fine for things like “re-position the comment cards when the content shifts”.
IntersectionObserver (briefly)
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) loadImage(entry.target);
}
},
{ root: null, rootMargin: "200px", threshold: 0 },
);
observer.observe(img);root: nullmeans the viewport. Pass a scroll container to measure against that instead.rootMargin: "200px"starts loading a bit before the element scrolls into view.thresholdis how much of the element must be visible (0 to 1, or a list).
Used for lazy loading images, infinite scroll (“load more when the last item appears”), “mark as read when seen”, and pausing animations that are off screen. It runs off the main thread’s critical path and never forces layout, which is why it beats scroll listeners with getBoundingClientRect.
For plain image lazy loading, <img loading="lazy"> does it with no JavaScript.
A real-world example
In a rich document editor:
- ResizeObserver sits behind most UI that has to line up with content as it reflows: a floating scrollbar and sticky header on wide tables, column widths, comment popovers, a table of contents, a graph canvas, a diagram viewer, and scroll sync between a source pane and a preview pane.
- MutationObserver is used in a handful of places that react to DOM changes made by something else, usually the editor or a third-party library: keeping a comment popover pinned, scroll sync, a graph view, a page header.
- IntersectionObserver is used where a screen needs to know what’s on screen, such as an editor hosted inside a mobile app.
Gotchas
- ResizeObserver fires once right after
observe(). Don’t also measure on mount. - Changing the observed element’s size inside the callback causes the “loop completed” error.
- Round sizes and skip unchanged values, or sub-pixel changes cause endless re-renders.
- One observer can watch many elements. Prefer that.
- Always
disconnect()on unmount. - MutationObserver callbacks are batched microtasks, not synchronous.
- Changing the watched DOM inside a MutationObserver callback loops.
- Don’t edit an editor-owned DOM from outside.
- Use CSS container queries when you only need styles.
Practice
- Make a card that switches between a row and a column layout at 400 px with ResizeObserver. Then redo it with a container query and compare.
- Trigger the ResizeObserver loop error on purpose. Then fix it.
- Write a MutationObserver that logs every node a third-party script adds to the page (try it on a site with ads, in the console).
- Build infinite scroll with IntersectionObserver on a sentinel element at the bottom of a list.
Sources worth reading
- MDN: ResizeObserver, MutationObserver, Intersection Observer API
- web.dev: ResizeObserver: it’s like document.onresize for elements
- ProseMirror guide: the view section, on how the editor manages its DOM