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.
contentRectmeasures 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.
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.
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.
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.
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.
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
Load early with rootMargin
In the first example, add
rootMargin: "40px"to the options. Predict which extra box is reported asinin round 1, then check. - 2
Fire only when half visible
Change
threshold: 0tothreshold: 0.5. Box 1 is only partly visible at the start (40 of its 60 px). Predict whether it'sinin round 1. - 3
See the batched callback
In the MutationObserver example, add
console.log("taken:", mo.takeRecords().length);right afterconsole.log("script finished"). Predict: does the first callback still run?takeRecords()empties the queue synchronously.
Code & diagrams
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
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 inThe 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
Console
Nothing logged yet.Expected console output
width 150 narrow layout
width 260 wide layout
width 180 narrow layout
disconnected: no more reportsThe 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
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 1Break 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, {}).
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.
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
trackVisibilityanddelaywithentry.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. - ▸
rootMarginonly 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 (
DOMNodeInsertedand 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
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
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), anddisconnect()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
**
IntersectionObservertells you when a target starts or stops intersecting (overlapping) a root**: the viewport by default, or a scrollable element you pass asroot. Each entry hastarget,isIntersecting,intersectionRatio(how much is visible, 0 to 1), and rectangles.thresholdsays at which visible fractions to report (0= any pixel,1= fully visible, or an array like[0, 0.5, 1]), androotMargingrows 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
**
ResizeObserverreports when an element's size** changes for any reason: the window resized, text was added, a sidebar opened, a font loaded. Entries havecontentRect(the content box: inside the padding) andborderBoxSize/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/csscourse) now handle many layout cases with no JavaScript at all; useResizeObserverwhen code must react, such as redrawing a<canvas>. - 5
**
MutationObserverreports changes to the DOM tree**: children added or removed (childList: true), attributes changed (attributes: true, optionallyattributeFilter: ["class"]andattributeOldValue: true), text changed (characterData: true), and withsubtree: trueanywhere below the target. Each record has atype,target,addedNodes,removedNodes,attributeNameandoldValue. 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
When callbacks run matters.
MutationObservercallbacks 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.ResizeObservercallbacks run during the rendering steps, after layout and before paint, so you can adjust things without a visible flicker.IntersectionObservercallbacks 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
Why are observers better than scroll and resize listeners for visibility and size checks?
How would you lazy-load images with IntersectionObserver? What do rootMargin and threshold do?
What does MutationObserver report, and when does its callback run?
When would you use ResizeObserver instead of CSS container queries?
Practice
Write onceVisible(el, fn) that calls fn() the first time el becomes visible, then stops observing.
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
attributesandcharacterDataproduces huge numbers of records; filter withattributeFilterand 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,rootMarginandthreshold.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.