Observers
Learn the browser observer APIs for reacting to visibility, DOM mutations, element resizing, and performance entries without constant polling.
- 01Watch visibilityUse IntersectionObserver with roots, thresholds, root margins, lazy loading, and sentinels.
- 02Read DOM changes in batchesUse MutationObserver options, records, microtask timing, takeRecords, and disconnect.
- 03Observe size and performanceUse ResizeObserver and PerformanceObserver safely with feature detection.
Watchers, not loops
Many browser features are about noticing change. A card becomes visible. A third-party widget adds a node. A component grows wider. A performance measurement is recorded. You could check those things over and over in a timer, but that wastes work and often checks at the wrong time.
The browser gives you a better pattern: observers. You create an observer with a callback, tell it what to watch, and let the browser call you later with entries or records. Each observer specializes in one signal.
An observer is a browser object that watches a target for a specific kind of change and asynchronously delivers entries or records to your callback.
| Observer | Signal it watches | Callback receives | Common use |
|---|---|---|---|
IntersectionObserver | Visibility crossing a threshold inside a root | IntersectionObserverEntry objects | Lazy loading, scroll reveal, infinite-list sentinels |
MutationObserver | DOM children, attributes, or text changed | MutationRecord objects | Integrating widgets, detecting attribute changes, editor tools |
ResizeObserver | An element's box size changed | ResizeObserverEntry objects | Container-driven components, charts, split panes |
PerformanceObserver | Performance timeline entries were recorded | PerformanceEntry objects | Measures, marks, and feature-detected web-vitals style metrics |
This lesson builds each one with a real lab. The key habit is simple: name the signal first, then choose the observer that watches that signal.
IntersectionObserver: visibility without scroll math
INTERACTIVEIntersectionObserver tells you when a target crosses visibility thresholds inside a root rectangle. With root: null, the root is the viewport. With root set to a scrollable element, the root is that element. This is the API behind many lazy images, scroll reveal effects, active table-of-contents markers, and infinite lists.
Picture a lifeguard assigned to one swimming zone. The lifeguard does not measure every swimmer every millisecond. They call out when a watched swimmer enters or leaves enough of the zone to matter. That is the observer's job.
- In real life: A swimming zone
- In JavaScript: The
root: viewport or scroll box - In real life: A swimmer being watched
- In JavaScript: A target element passed to
observe() - In real life: How much of the swimmer is inside
- In JavaScript:
intersectionRatio - In real life: Call out when half the swimmer enters
- In JavaScript:
threshold: 0.5 - In real life: Widen or shrink the zone rope
- In JavaScript:
rootMarginsuch as80px 0px
Where the analogy stops: A lifeguard reacts immediately with human judgment. IntersectionObserver callbacks are asynchronous, can be batched, and report geometry the browser calculated.
Three settings matter most. threshold is a number from 0 to 1, or an array like [0, 0.5, 1]. rootMargin expands or shrinks the root rectangle using px or %. And the first callback is queued soon after observe(), even before the user scrolls, so your code knows the current state.
const observer = new IntersectionObserver(callback, { root: scrollBox, threshold: [0, 0.5, 1], rootMargin: "40px 0px",});observer.observe(card);observer.observe(sentinel);Scroll the lab. The observer will report the current state once after observe, then on threshold crossings. Loaded placeholders: 0. Rendered cards: 8.
In the lab, each card starts as a placeholder. When a card intersects, the code swaps in an in-page colored block. The sentinel at the bottom appends more cards when it becomes visible. That is the infinite-list pattern: observe one tiny marker instead of calculating scroll positions yourself.
MutationObserver: DOM changes as batched records
STEP THROUGHMutationObserver watches a DOM subtree for structural, attribute, and text changes. It is not a replacement for ordinary events. Use events when you control the user action. Use a mutation observer when code you do not directly control changes the DOM, or when a tool needs to audit changes after they happen.
Imagine a building clerk writing every change to a list while workers renovate. The clerk does not run to you after each nail. When the current burst of work finishes, you get the list in one batch.
- In real life: The building
- In JavaScript: The observed target node
- In real life: A renovated room, new sign, or changed label
- In JavaScript: Child, attribute, or text mutations
- In real life: The clerk's list
- In JavaScript: Queued
MutationRecordobjects - In real life: One batch handed over after the crew pauses
- In JavaScript: One callback after the current script, as a microtask
Where the analogy stops: A clerk may choose wording freely. MutationObserver records use exact browser-defined fields such as type, target, addedNodes, attributeName, and oldValue.
The options object decides which records can appear: childList, attributes, subtree, characterData, and old-value flags such as attributeOldValue. At least one of child, attribute, or character data watching must be enabled.
Predict the order. Then step through the pure model of a MutationObserver batch.
script
log.push("sync: start");const records = [];records.push("childList: added item");records.push("attributes: data-state old=closed");records.push("characterData: title changed");log.push("sync: end");log.push("callback: " + records.length + " records");console.log(log.join(" | "));const log = [];log.push("sync: start");const records = [];records.push("childList: added item");records.push("attributes: data-state old=closed");records.push("characterData: title changed");log.push("sync: end");log.push("callback: " + records.length + " records");console.log(log.join(" | "));Toggle options, then make one change or three synchronous changes.
Timing is the important professional detail. MutationObserver callbacks run as microtasks after the current script has finished, which connects directly to the Microtasks in depth lesson. If you need queued records immediately, call takeRecords(). When you are finished, call disconnect() so the observer stops holding onto targets.
ResizeObserver: element size, not just window size
INTERACTIVEA component can become narrow because a sidebar opened, text wrapped, a parent grid changed, or a font loaded. None of those necessarily fires window.resize. ResizeObserver watches the element itself and runs after layout, before paint.
A good tailor measures the garment, not the room. ResizeObserver does the same for UI: it watches the box you care about, even if the viewport stayed the same.
- In real life: The garment being fitted
- In JavaScript: The observed element
- In real life: A fresh measurement
- In JavaScript:
contentRectorborderBoxSize - In real life: Checking again after the wearer moves
- In JavaScript: A callback after the browser recalculates layout
Where the analogy stops: A tailor can tug the garment while measuring. A ResizeObserver callback should not resize the observed element directly, or it can trigger a resize loop warning.
const observer = new ResizeObserver((entries) => { for (const entry of entries) { console.log(entry.contentRect.width); console.log(entry.borderBoxSize?.[0]?.inlineSize); }});observer.observe(panel, { box: "border-box" });The class changes from React state, not from inside the observer callback.
Move the slider. ResizeObserver will report the element's size after layout.
The default box is content-box. You can request border-box or device-pixel-content-box where supported. The older contentRect property is widely available; newer box sizes may be arrays. Read sizes in the callback, then update state or classes carefully. Do not resize the observed element inside its own callback.
PerformanceObserver: timing cards from the browser
LABThe Performance Timeline records entries: marks, measures, resource timings, navigation timings, and some browser-specific metrics. PerformanceObserver lets you receive those entries as they are recorded. The safest beginner entry type is measure, because you create it yourself with performance.mark() and performance.measure().
A race timekeeper does not change how fast the runner moves. They record timing cards. PerformanceObserver is the same: it observes performance data; it does not make code faster by itself.
- In real life: Start and finish flags
- In JavaScript:
performance.mark()calls - In real life: A timing card
- In JavaScript: A
PerformanceEntry - In real life: Handing cards to the coach
- In JavaScript: The observer callback receives entries
- In real life: Older cards on the clipboard
- In JavaScript:
buffered: truewhere supported
Where the analogy stops: A timekeeper records one race. The Performance Timeline can contain many entry types, and support differs across browsers.
const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { console.log(entry.name + ": " + entry.duration.toFixed(2) + "ms"); }});observer.observe({ entryTypes: ["measure"], buffered: true });performance.mark("work:start");// do a small taskperformance.mark("work:end");performance.measure("demo work", "work:start", "work:end");Supported entry types here: Checking after mount...
Run the task to create marks and a measure entry.
Feature detection matters. Entry types such as longtask, layout-shift, and largest-contentful-paint are especially Chromium-oriented. Check PerformanceObserver.supportedEntryTypes after mount and observe only types this browser reports.
Which observer?
SORTThe names are long, but the decision is short: visible, changed, resized, or timed? Sort each realistic task by the signal it depends on.
- Load a card image when it scrolls near a list viewport
- Append another page when a bottom sentinel becomes visible
- Notice a widget inserted a new child node
- React when
aria-expandedchanges - Switch a card component when its own width drops below 360px
- Re-measure a resizable split pane
- Collect durations from
performance.measure() - Feature-detect Largest Contentful Paint entries in Chromium
Read each task, name the signal, then choose visibility, DOM changes, element size, or timings.
Where you'll use this
Observers shine when the browser already knows the answer better than your code can guess it. Use IntersectionObserver to lazy-load media shortly before it appears, highlight a section link when a heading crosses the viewport, or append more rows when a sentinel appears. Pair it with cleanup: once a one-time image loads, unobserve(image) or disconnect().
Use MutationObserver at integration boundaries: a rich-text editor, a CMS embed, analytics that records when a third-party widget appears, or a migration script that repairs attributes after a library changes markup. Observe the smallest target and narrow the options. Observing the whole document with subtree: true can produce a noisy stream.
Use ResizeObserver for container-driven layout. It complements the Sizes, scrolling & coordinates lesson: geometry reads are still real layout facts, but the observer tells you when to read. Use PerformanceObserver for instrumentation: your own marks and measures first, advanced metrics only after feature detection.
Every observer you create should have a matching cleanup path. Stop watching when the target is gone, the component unmounts, or the one-time job is complete.
Common misconceptions
- “Observers run synchronously.” They do not. Intersection callbacks are asynchronous, mutations are delivered in a microtask, ResizeObserver runs after layout, and PerformanceObserver follows the performance timeline.
- “Threshold 1 means visible at all.” It means the target is fully visible inside the root. Use 0 or a small threshold for “any part entered.”
- “MutationObserver is an event listener for clicks.” Use events for user actions. Use mutation records for DOM changes after they happen.
- “Window resize covers component resizing.” A card can resize while the window stays still.
- “All performance entry types work everywhere.” Some important types are browser-specific. Feature-detect.
- “Disconnect is optional cleanup.” In long-lived interfaces, missing cleanup can leave stale callbacks and retained targets.
Practice exercises
5 EXERCISESRead the program and predict the number printed.
function ratio(target, root) {
const right = Math.min(target.left + target.width, root.left + root.width);
const left = Math.max(target.left, root.left);
const bottom = Math.min(target.top + target.height, root.top + root.height);
const top = Math.max(target.top, root.top);
const area = Math.max(0, right - left) * Math.max(0, bottom - top);
return area / (target.width * target.height);
}
console.log(ratio({ left: 0, top: 50, width: 100, height: 100 }, { left: 0, top: 0, width: 100, height: 100 }));0.5The overlap is 100 by 50, so 5,000 visible pixels divided by 10,000 target pixels is 0.5.
MutationObserver callbacks use microtask timing. This small program models the same ordering.
const log = [];
log.push("sync");
queueMicrotask(() => log.push("observer"));
log.push("done");
queueMicrotask(() => console.log(log.join(", ")));sync, done, observerThe current script pushes sync, queues a microtask, pushes done, then the microtask pushes observer and prints the joined log.
Suppose a callback reads panel.contentRect.width and then immediately sets panel.style.width. Should it resize the observed element from inside its own callback?
const observer = new ResizeObserver((entries) => {
const width = entries[0].contentRect.width;
panel.classList.toggle("is-narrow", width < 360);
});Do not write panel.style.width = ... inside the observer for panel. Read the size and update unrelated state or classes carefully.
Run the snippet mentally. What is the entry type?
const entry = { name: "demo", entryType: "measure", duration: 4.567, startTime: 12.34 };
console.log(entry.entryType + " " + entry.name + ": " + entry.duration.toFixed(2) + "ms");measureThe program prints a line beginning with measure demo, so the entry type is measure.
Pick observers for a dashboard with a resizable chart, an infinite activity feed, and a third-party ad slot that inserts markup later.
// Chart panel
new ResizeObserver(updateChart).observe(chartPanel);
// More feed items
new IntersectionObserver(loadMore, { root: feed }).observe(sentinel);
// Third-party ad slot
new MutationObserver(auditAdSlot).observe(adSlot, { childList: true });The dashboard uses three signals, so it uses three observers. Keeping each observer focused makes cleanup and debugging easier.
Check your understanding
8 QUESTIONSQuestion 1 of 8What does
root: nullmean for IntersectionObserver?Choose an answer to see the explanation.
Question 2 of 8Which threshold fires when an element becomes at least half visible?
Choose an answer to see the explanation.
Question 3 of 8What does the mutation batching model print?
Read the code, then predictconst log = []; log.push("sync: start"); const records = []; records.push("childList"); records.push("attributes"); log.push("sync: end"); log.push("callback: " + records.length); console.log(log.join(" | "));Choose an answer to see the explanation.
Question 4 of 8Which MutationObserver method empties pending records immediately?
Choose an answer to see the explanation.
Question 5 of 8When does ResizeObserver deliver notifications in the rendering pipeline?
Choose an answer to see the explanation.
Question 6 of 8Which ResizeObserver box is the default?
Choose an answer to see the explanation.
Question 7 of 8What does this performance formatter print?
Read the code, then predictconst entry = { name: "demo", entryType: "measure", duration: 4.567, startTime: 12.34 }; console.log(entry.entryType + " " + entry.name + ": " + entry.duration.toFixed(2) + "ms at " + entry.startTime.toFixed(1) + "ms");Choose an answer to see the explanation.
Question 8 of 8Which PerformanceObserver entry types need feature detection because support varies by browser?
Choose an answer to see the explanation.
Key takeaways
- Use observers when the browser can notify you about a change more efficiently than a polling loop.
- IntersectionObserver watches visibility inside a root; thresholds can be arrays and
rootMargincan preload early. - MutationObserver batches DOM change records and delivers them as a microtask after the current script.
- ResizeObserver watches element boxes after layout and before paint; avoid resize loops.
- PerformanceObserver watches timeline entries; feature-detect browser-specific entry types.
An observer is a focused browser watcher: configure what counts, observe targets, react to asynchronous entries, and disconnect when done.
Up next: Animation with JavaScript.