Command Palette

Search for a command to run...

Hectal
PHASE 11Intermediate ~23 min· topic 6 of 8

Topic 11.6

Observers

In one line

Observers let the browser tell you when something changes instead of you checking again and again: IntersectionObserver reports when elements enter or leave view, ResizeObserver when an element's size changes, and MutationObserver when the DOM itself is changed.

Think of it like this

A doorbell versus walking to the door. You could walk to the front door every minute to check whether a friend has arrived (that's polling: checking again and again). Or you can fit a doorbell and get on with your day until it rings. Observers are doorbells for the page: you say what to watch, and the browser rings when it happens, so your code does nothing in between.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Polling
Checking something again and again on a timer to see if it changed.
Observer
An object that watches something and calls your function when it changes.
Callback
The function you give the observer, which the browser calls with the changes.
Entry / record
One item in the list the callback receives, describing one change.
Intersect
Overlap. An element intersects the viewport when any part of it is on screen.
Root and rootMargin
The area an IntersectionObserver compares against (the viewport by default) and extra space added around it.
Threshold
How much of the element must be visible (0 to 1) before the observer reports a change.
Content box
The inside of an element, without its padding and border. contentRect measures it.
Microtask
A small job that runs right after the current script, before the browser does anything else (Topic 10.3).

Step by step

01The old way: scroll + measure

This runs dozens of times per second while scrolling, and each getBoundingClientRect() may force a layout calculation.

old-way.jswhole filejavascript
window.addEventListener("scroll", () => {
  for (const img of images) {
    const r = img.getBoundingClientRect();
    if (r.top < innerHeight && r.bottom > 0) load(img);
  }
});

02The observer way

Same result, no scroll listener. rootMargin starts the download a little before the image appears, and unobserve stops watching once it's loaded.

lazy.jswhole filejavascript
const io = new IntersectionObserver((entries) => {
  for (const entry of entries) {
    if (!entry.isIntersecting) continue;
    entry.target.src = entry.target.dataset.src;
    io.unobserve(entry.target);
  }
}, { rootMargin: "200px" });
document.querySelectorAll("img[data-src]").forEach((img) => io.observe(img));

03React to an element's own size

A card switches to a wide layout when it is wider than 400 px, wherever it's placed.

resize.jswhole filejavascript
const ro = new ResizeObserver((entries) => {
  for (const entry of entries) {
    const width = entry.contentBoxSize[0].inlineSize;
    entry.target.classList.toggle("wide", width > 400);
  }
});
ro.observe(document.querySelector(".card"));

04Watch the DOM for changes

Say exactly what to watch; at least one of childList, attributes or characterData is required.

mutation.jswhole filejavascript
const mo = new MutationObserver((records) => {
  for (const r of records) console.log(r.type, r.addedNodes.length);
});
mo.observe(document.querySelector("#chat"), { childList: true, subtree: true });

05When each callback runs

All three are batched, and each is delivered at a different point of the event loop.

When each callback runsdiagram
Rendering diagram…

06Clean up

Call disconnect() when the component goes away. For one-shot work (lazy loading, "seen once" analytics), unobserve each target as soon as it has done its job.

Try it yourself

  1. 1

    Load early with rootMargin

    In the first example, add rootMargin: "40px" to the options. Predict which extra box is reported as in in round 1, then check.

  2. 2

    Fire only when half visible

    Change threshold: 0 to threshold: 0.5. Box 1 is only partly visible at the start (40 of its 60 px). Predict whether it's in in round 1.

  3. 3

    See the batched callback

    In the MutationObserver example, add console.log("taken:", mo.takeRecords().length); right after console.log("script finished"). Predict: does the first callback still run? takeRecords() empties the queue synchronously.

Code & diagrams

IntersectionObserver inside a scrolling box New tab

Round 1 is the starting report for every box. After scrolling, only the boxes whose state changed are reported. Scroll the grey box yourself and watch the colours.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

round 1: Box 0 in
round 1: Box 1 in
round 1: Box 2 out
round 1: Box 3 out
round 1: Box 4 out
round 1: Box 5 out
round 2: Box 0 out
round 2: Box 1 out
round 2: Box 3 in
round 2: Box 4 in
ResizeObserver: a component that adapts to its own width New tab

The first report is the starting size. The slot has a fixed height, so switching the card's layout doesn't change the observed size. Observing the card itself would: its height changes with the layout, which triggers another report in the same frame (see Break it).

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

width 150 narrow layout
width 260 wide layout
width 180 narrow layout
disconnected: no more reports
MutationObserver: batched records, delivered as a microtask New tab

The three changes made by the script arrived together, after `script finished`, as one microtask. Setting `textContent` replaces the `<li>`'s text node, so it's a `childList` change on the `<li>` (seen because of `subtree: true`), not a `characterData` change.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

script finished
callback with 3 records
  childList: added 1 removed 0
  attributes: class old value: null
  childList: added 0 removed 1
callback with 1 records
  childList: added 1 removed 1

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Observe with no options

In the MutationObserver example, write mo.observe(list, {}).

terminal
$ Console
── what you'll see ──
Uncaught TypeError: Failed to execute 'observe' on 'MutationObserver': The options object must set at least one of 'attributes', 'characterData', or 'childList' to true.

Break #2

Change the size you are observing

In the ResizeObserver example, observe the card instead of the slot: ro.observe(card), and change slot.style.width lines to card.style.width.

terminal
$ Console
── what you'll see ──
width 150 narrow layout
width 260 wide layout
Uncaught ResizeObserver loop completed with undelivered notifications.
width 260 wide layout
...

Myth vs fact

Myth

IntersectionObserver only works for the whole window.

Fact

Pass any scrollable ancestor as root. With no root, it uses the viewport (the top-level one, even from inside an iframe).

Myth

MutationObserver fires once for every change, immediately.

Fact

It queues records and delivers them in one batch as a microtask after the current script, so a loop that adds 1,000 nodes gives one callback with 1,000 records.

Myth

ResizeObserver is the same as the window resize event.

Fact

It watches elements, so it fires when content, fonts or layout change the element's size even if the window never changes.

Myth

Observers are slow because they watch all the time.

Fact

They use information the browser computes anyway during rendering. They're far cheaper than scroll handlers that measure elements.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    IntersectionObserver v2 adds trackVisibility and delay with entry.isVisible, which also checks that nothing covers the element and it isn't transparent: useful against click-jacking-style ad fraud. It costs more and is Chromium-only.

  • ▸

    rootMargin only applies when the root is the document's own viewport or an element; for cross-origin iframes observing the top-level viewport, the margin is ignored for privacy reasons. scrollMargin (newer) applies margins to nested scroll containers.

  • ▸

    ResizeObserver can observe different boxes: ro.observe(el, { box: "border-box" }) or "device-pixel-content-box", the latter giving exact device pixels for crisp <canvas> rendering on high-DPI screens.

  • ▸

    MutationObserver replaced the old synchronous mutation events (DOMNodeInserted and friends), which were very slow because they fired during every change. Chrome removed those in 2024, so old scripts relying on them silently stop working.

Remember this

  1. 1

    Before observers, code used scroll and resize events plus measurements such as getBoundingClientRect() to find out whether an element was visible or how big it was. Scroll events fire many times per second, and each measurement can force the browser to calculate layout (Topic 8.8, layout thrashing), which makes scrolling janky. The three observers move that work into the browser, which already knows the answers during its rendering steps, and batch the results into one callback.

  2. 2

    All three share one shape: create the observer with a callback, call observe(target, options) for each element to watch, and receive an array of entries (or records) describing what changed. unobserve(target) stops watching one element (Intersection and Resize), and disconnect() stops everything. Disconnect observers you no longer need, for example when a component is removed, so they don't keep elements alive in memory.

  3. 3

    **IntersectionObserver tells you when a target starts or stops intersecting (overlapping) a root**: the viewport by default, or a scrollable element you pass as root. Each entry has target, isIntersecting, intersectionRatio (how much is visible, 0 to 1), and rectangles. threshold says at which visible fractions to report (0 = any pixel, 1 = fully visible, or an array like [0, 0.5, 1]), and rootMargin grows or shrinks the root like CSS margins ("200px" starts loading 200 px before the element scrolls into view). Uses: lazy-loading images and data, infinite scroll, "seen" analytics, starting animations when sections appear, sticky-header tricks. The callback also runs once at the start for every observed target, with its current state.

  4. 4

    **ResizeObserver reports when an element's size** changes for any reason: the window resized, text was added, a sidebar opened, a font loaded. Entries have contentRect (the content box: inside the padding) and borderBoxSize/contentBoxSize (arrays of { inlineSize, blockSize }, which respect writing direction). It's ideal for components that adapt to their own width (charts, canvases, text that must fit) rather than the window's. CSS container queries (Hectal's /css course) now handle many layout cases with no JavaScript at all; use ResizeObserver when code must react, such as redrawing a <canvas>.

  5. 5

    **MutationObserver reports changes to the DOM tree**: children added or removed (childList: true), attributes changed (attributes: true, optionally attributeFilter: ["class"] and attributeOldValue: true), text changed (characterData: true), and with subtree: true anywhere below the target. Each record has a type, target, addedNodes, removedNodes, attributeName and oldValue. Uses: reacting to DOM changes made by code you don't control (third-party widgets, a CMS), editors, and dev tools. Don't use it to watch your own code's changes: call your update function directly instead.

  6. 6

    When callbacks run matters. MutationObserver callbacks run as a microtask (Topic 10.3): after the current script finishes, but before the browser paints, with all changes from that script in one batch. ResizeObserver callbacks run during the rendering steps, after layout and before paint, so you can adjust things without a visible flicker. IntersectionObserver callbacks are queued as a task after the browser has computed the frame, so they're slightly delayed and never block scrolling. None of them fire for every tiny change: they're batched by design.

Explain it without notes

01

Why are observers better than scroll and resize listeners for visibility and size checks?

02

How would you lazy-load images with IntersectionObserver? What do rootMargin and threshold do?

03

What does MutationObserver report, and when does its callback run?

04

When would you use ResizeObserver instead of CSS container queries?

Practice

01

Write onceVisible(el, fn) that calls fn() the first time el becomes visible, then stops observing.

02

Use a MutationObserver to log New message: <text> whenever an <li> is added to #chat, then add one with code.

Trade-offs

  • ↔

    IntersectionObserver is efficient and asynchronous, but it reports a little after the fact; for effects that must be pixel-perfect on every scroll frame (parallax tied to scroll position), CSS scroll-driven animations are the better tool.

  • ↔

    MutationObserver can watch anything, but watching a large subtree with attributes and characterData produces huge numbers of records; filter with attributeFilter and keep the observed area small.

Done when you can

  • I can explain why observers beat scroll/resize polling.

  • I can lazy-load or reveal elements with IntersectionObserver, using root, rootMargin and threshold.

  • I can react to an element's own size with ResizeObserver.

  • I can watch DOM changes with MutationObserver and know its callback is a batched microtask.

  • I disconnect or unobserve when I'm done.