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.

ObserverTells you when…
ResizeObserveran element’s size changes
MutationObserverthe DOM tree changes: nodes added or removed, attributes changed, text changed
IntersectionObserveran 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 canvas

Each 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 requestAnimationFrame if 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 (useEffect return). 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: true on document.body with characterData on 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 (requestAnimationFrame or requestIdleCallback).

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:

  1. Read only. If you change the editor’s DOM from outside, the editor will either undo your change or think the user typed it.
  2. 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: null means the viewport. Pass a scroll container to measure against that instead.
  • rootMargin: "200px" starts loading a bit before the element scrolls into view.
  • threshold is 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

  1. 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.
  2. Trigger the ResizeObserver loop error on purpose. Then fix it.
  3. 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).
  4. 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