Web Platform APIs · 10 / 10
lesson 10

Observers and workers

IntersectionObserver, ResizeObserver, MutationObserver, and Web Workers in one lesson.

~ 17 min read·lesson 10 of 10
0 / 10

This lesson groups four APIs that have nothing to do with each other on the surface, but share a single question: how does the browser tell my code that something changed without me having to ask? Observers watch a thing and call your callback when it changes. Workers move heavy code off the main thread so your UI stays smooth. Once you've seen one observer, the others are tiny variations — and once you've seen a Web Worker, the rest of the platform's threading story makes sense.

This is the densest lesson in the course because each API is small enough that it doesn't need a lesson of its own. Don't try to memorize every option. Get the shape — when you reach for one of these in real code, the syntax will land in five minutes.

IntersectionObserver

IntersectionObserver tells you when an element scrolls into or out of view. It's how lazy-loading, infinite scroll, and "fade in when you reach the section" effects are built today.

The naive way is scroll listeners and getBoundingClientRect() on every frame — slow, error-prone, and easy to get wrong. The observer is the right shape: the browser does the visibility math at idle time and calls your callback only when something actually changed.

lazy-load.js
const observer = new IntersectionObserver((entries) => {
for (const entry of entries) {
  if (entry.isIntersecting) {
    const img = entry.target;
    img.src = img.dataset.src;        /* swap in the real source */
    observer.unobserve(img);          /* stop watching this one */
  }
}
});

document.querySelectorAll("img[data-src]").forEach((img) => observer.observe(img));

Walk through the snippet:

  • new IntersectionObserver(callback) creates the observer. The callback receives an array of entries — one per element that crossed the visibility threshold since the last call.
  • entry.isIntersecting is true when the element is at least partially in view, false when it's not.
  • observer.observe(element) starts watching. observer.unobserve(element) stops. observer.disconnect() stops everything.

There's a second argument, an options object, with threshold (how much of the element has to be visible — 0 means "any pixel", 1 means "the whole thing", 0.5 means "halfway"), root (which scrollable parent to measure against — defaults to the viewport), and rootMargin (a CSS-shaped offset like "100px" to fire early or late).

Tip

For infinite scroll, observe a sentinel element at the end of the list. When it intersects, fetch the next page and append. The same observer can then unobserve the old sentinel and observe the new one — no scroll listeners.

ResizeObserver

Same shape, different question: did this element's size change? Useful for layout-aware components — a chart that needs to redraw when its container resizes, a textarea that grows with its content, anything that depends on its own width or height.

watch-size.js
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
  const { width, height } = entry.contentRect;
  redraw(entry.target, width, height);
}
});

observer.observe(chartElement);

entry.contentRect is a DOMRect with width and height. The callback fires once when you start observing (so you have an initial size) and again every time the size changes — never on a fixed schedule, only on actual change.

Why this is better than the old window.resize event: ResizeObserver fires for element size changes, not just the viewport. A flex sibling growing changes your size; the viewport didn't move. The old approach missed that case entirely.

check your understanding
You add a chart inside a flex container. When a sibling's content changes and the chart's width shrinks, you want the chart to re-render. Which observer is the fit?

MutationObserver

MutationObserver fires when the DOM itself changes — children added or removed, attributes changed, text edited. Useful for code that has to react to changes made by other code (third-party widgets, framework escape hatches, content scripts).

watch-dom.js
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
  if (mutation.type === "childList") {
    console.log("added:", mutation.addedNodes);
  }
}
});

observer.observe(targetElement, {
childList: true,    /* watch for added/removed children */
attributes: true,   /* watch for attribute changes */
subtree: true,      /* and watch all descendants too */
});

The options object is required, and it tells the observer what to watch. Without subtree: true, it only watches direct children of the target.

This is a power-user API. Most apps don't need it — if you control the DOM, you can usually run code at the moment you change it. Reach for MutationObserver when you don't control the changes (or you don't know when they'll happen). It's most useful when integrating with code that wasn't written with observers in mind.

IntersectionObserver is it visible? ResizeObserver what's its size? MutationObserver did the DOM change?all three: observe, get callback on change, disconnect when done
The three observers — each watches a different question and calls your callback only when the answer changes.

Web Workers

Switching gears completely. JavaScript on the main thread shares time with rendering. If your code spends 200ms parsing a CSV or running a hash, the page freezes for 200ms — buttons don't respond, animations stutter. A Web Worker is a separate thread that runs your code without blocking the main one.

Workers are isolated. They have no access to the DOM or to window. They communicate with the main thread by exchanging messages — like sending letters between two rooms.

use-worker.js
/* main thread */
const worker = new Worker("./hash-worker.js", { type: "module" });

worker.postMessage({ text: bigString });
worker.addEventListener("message", (event) => {
console.log("hash:", event.data);
});
hash-worker.js
/* runs in a separate thread */
self.addEventListener("message", async (event) => {
const data = new TextEncoder().encode(event.data.text);
const buffer = await crypto.subtle.digest("SHA-256", data);
self.postMessage(Array.from(new Uint8Array(buffer)));
});

The shape is symmetrical: both sides have postMessage to send and a message listener to receive. Inside the worker, self is the worker's global scope — there's no window, no document.

What workers are for: heavy CPU work that would freeze the UI. Image processing, large parsing, cryptography, expensive math. What they're not for: making fetch calls faster (the main thread isn't the bottleneck for fetch), or simplifying state management (the message-passing overhead can dominate small tasks).

Watch out

Messages are copies, not references. Sending a 50 MB array between threads is slow and doubles memory. For large binary data, use Transferable objects (like ArrayBuffer) — the browser hands ownership to the worker without copying.

A note on service workers

Service workers are a different beast. They sit between your page and the network and can serve cached responses, enable offline mode, and receive push notifications. They're how Progressive Web Apps work. The shape is similar to a Web Worker (a separate file, message passing, no DOM access), but the lifecycle and use cases are different enough that they deserve their own course. Treat this paragraph as a hook: when "make this app work offline" becomes a real requirement, that's when you go learn service workers.

check your understanding
You're parsing a 30 MB CSV file in the browser and the UI freezes for several seconds. What's the right tool?

Try it yourself

check your understanding
You set up an IntersectionObserver with threshold: 0.5. When does the callback fire for a given target?
check your understanding
Your Web Worker sends back a 50 MB ArrayBuffer on every message. The page slows down anyway. What's the fix?
check your understanding
Which observer is the right tool for "run my code whenever a third-party script appends children to this container"?
check your understanding
You stop needing to watch an element with an observer. Which call leaves the rest of the observer's targets untouched?
← prevfinish course →
KeepLearningcertificate
for completing
Web Platform APIs
0 of 10 read