Observing the Page: Intersection, Resize & Performance Observers
For years, “run some code when an element scrolls into view” meant attaching a scroll listener, reading getBoundingClientRect() on every fire, and praying the math stayed cheap. It never did. Scroll events fire dozens of times a second, each read forces the browser to flush layout, and your smooth 60fps timeline turns into a sawtooth of jank.
The observer APIs exist to end that pattern. Instead of you polling the page on every event, you register interest once and the browser calls you back — asynchronously, batched, and computed off the hot scroll path. Three of them cover the questions you actually ask about a live page:
- IntersectionObserver — is this element visible in the viewport (and how much)?
- ResizeObserver — did this element’s box change size?
- PerformanceObserver — did a paint, a long task, or a layout shift just happen?
They share a shape: new XObserver(callback), then observe(target), and the callback receives an array of entries. Learn one and the other two feel familiar. Let’s take them in turn.
IntersectionObserver
The core question: how much of a target element overlaps a scrolling container (or the viewport)? You give the browser a target and a set of thresholds, and it tells you each time the visible ratio crosses one of them.
const observer = new IntersectionObserver(callback, options);
observer.observe(document.querySelector('#target'));
The callback runs with two arguments — an array of entries and the observer itself:
const observer = new IntersectionObserver((entries, obs) => {
for (const entry of entries) {
if (entry.isIntersecting) {
console.log(entry.target, 'is now visible');
}
}
});
Note that it fires once right after observe() with an initial entry, so you learn the starting state without waiting for a scroll. From then on it fires only when a threshold boundary is crossed.
root, rootMargin, threshold
Three options shape what “visible” means.
root— the element you measure against. Default isnull, meaning the browser viewport. Pass a scrollable ancestor to measure within a scroll container instead.rootMargin— a CSS-margin-like string that grows or shrinks the root’s box before intersection is computed."200px"starts firing 200px early (great for pre-loading);"-100px"waits until the element is 100px past the edge. Percentages are allowed; whenrootis the viewport, only pixel and percentage units work, not other CSS lengths.threshold— a ratio, or array of ratios, of the target’s area that must be visible.0(default) fires at the first pixel;1.0fires only when the whole element is inside;[0, 0.25, 0.5, 0.75, 1]fires at every quarter.
Each entry, field by field
An IntersectionObserverEntry is a read-only snapshot. The ones you reach for most:
isIntersecting—trueif the target overlaps the root at all right now. The single most-used field.intersectionRatio— how much of the target is visible,0to1. Useful for “at least half showing” logic.target— the observed element. Essential when one observer watches many elements.intersectionRect,boundingClientRect,rootBounds— the geometry: the overlapping rectangle, the target’s own rectangle, and the (margin-adjusted) root rectangle.time— aDOMHighResTimeStampof when the change was recorded, handy for measuring how long something stayed on screen.
Scroll the box below and watch the live entry. The observer’s root is the scroll container itself, and a threshold array of quarters makes intersectionRatio update as the target slides in and out — no scroll listener anywhere.
Real use: lazy-loading images
The classic case. Ship images with the real URL in data-src, observe them, and swap it into src only when they approach the viewport. A generous rootMargin means the image is usually decoded by the time the user scrolls to it.
const io = new IntersectionObserver((entries, obs) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const img = entry.target;
img.src = img.dataset.src;
obs.unobserve(img); // one-shot: stop watching once loaded
}
}, { rootMargin: '200px' });
document.querySelectorAll('img[data-src]').forEach(img => io.observe(img));
The unobserve(img) call is the important habit: once an image is loaded there’s nothing left to watch, so you release it. For lazy images specifically, the browser’s native loading="lazy" attribute now covers the simple case — reach for an observer when you need custom margins, analytics, or non-image content.
Real use: infinite scroll
Put a tiny sentinel <div> at the bottom of your list. When it intersects, fetch the next page. No scroll math at all.
const sentinel = document.querySelector('#sentinel');
const io = new IntersectionObserver(async ([entry]) => {
if (!entry.isIntersecting) return;
io.unobserve(sentinel); // guard against double-firing mid-fetch
await loadNextPage();
io.observe(sentinel); // re-arm after new content lands
});
io.observe(sentinel);
Real use: impression tracking
Ad and analytics teams care about a stricter question: was the element actually seen — say, at least half of it, for a beat? Combine a threshold with the time field.
const io = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.intersectionRatio >= 0.5) {
logImpression(entry.target.dataset.id, entry.time);
}
}
}, { threshold: [0, 0.5, 1] });
ResizeObserver
scroll had its hacks; so did resize. The window resize event only tells you the window changed — useless when an element’s size depends on flexbox, a sibling collapsing, or content reflowing. ResizeObserver watches an element’s box directly and fires when its dimensions change, whatever the cause.
const ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
entry.target.dataset.width = Math.round(width);
}
});
ro.observe(document.querySelector('#panel'));
This is what powers “container queries in JavaScript”: a chart that re-lays-out to fit its parent, a text component that swaps to a compact layout below 400px, a canvas that resizes its backing store. None of it involves the window.
Drag the box’s bottom-right corner. The callback reads entry.contentRect and flips to a compact layout under 220px wide — a container query, driven entirely by the element’s own size.
content-box vs border-box
An element has more than one “size.” Padding and border sit outside the content but inside the border edge, so ResizeObserver lets you pick which box you mean via the box option:
ro.observe(el, { box: 'content-box' }); // default — inside padding
ro.observe(el, { box: 'border-box' }); // includes padding + border
ro.observe(el, { box: 'device-pixel-content-box' }); // physical pixels, DPR-aware
The entry exposes all of them so you rarely need to reach back into the DOM:
entry.contentRect— aDOMRectReadOnly(withwidth,height,top,left). Convenient, and the oldest-supported field.entry.contentBoxSizeandentry.borderBoxSize— arrays of{ inlineSize, blockSize }objects.inlineSizeis width in horizontal writing modes,blockSizeis height; they flip in vertical writing modes, which is exactly why the spec uses logical names.entry.devicePixelContentBoxSize— the content box in real device pixels, the correct source of truth when sizing a<canvas>backing buffer on high-DPI screens.
The resize-loop warning
This one bites everyone eventually. If your callback changes the size of an element that’s being observed (or forces a reflow that does), the browser detects a new resize, wants to call you again, and you have the makings of an infinite loop. The browser refuses to hang: it delivers what it can this frame, defers the rest to the next, and logs a benign-but-alarming error:
ResizeObserver loop completed with undelivered notifications.
The fix is to break the synchronous cycle. Defer the mutation to the next frame with requestAnimationFrame, so the write lands after this observation round closes:
const ro = new ResizeObserver((entries) => {
requestAnimationFrame(() => {
for (const entry of entries) {
// safe: this layout write happens next frame,
// not inside the observation that triggered us
applyResponsiveLayout(entry.target, entry.contentRect.width);
}
});
});
Better still, avoid writing back a size that feeds the same measurement. Read the box, compute a class or breakpoint, and only touch the DOM when that discrete value actually changes — most frames it won’t, so most frames do no work.
PerformanceObserver
The first two observers watch geometry. This one watches time. The browser continuously records performance entries — paint timings, navigation milestones, resource loads, layout shifts, long tasks — into an internal buffer. PerformanceObserver subscribes to the types you name and delivers new entries as they’re produced, so you can measure the real page, on real user devices, instead of guessing from a lab run.
const po = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log(entry.entryType, entry.name, entry.startTime, entry.duration);
}
});
po.observe({ type: 'largest-contentful-paint', buffered: true });
Two things differ from the other observers. The callback gets a PerformanceObserverEntryList, not a plain array — call list.getEntries() (or getEntriesByType, getEntriesByName) on it. And you subscribe by entry type, not by DOM node.
You can watch this happen with your own timings. Click the button to run a chunk of busy work bracketed by performance.mark() and performance.measure(); the observer, subscribed to the measure entry type, reports each duration as the browser hands it over.
entryTypes, and the buffered flag
You can observe one type with options, or several types at once:
po.observe({ type: 'longtask', buffered: true }); // one type, with options
po.observe({ entryTypes: ['paint', 'resource'] }); // several, no per-type options
Use type (singular) when you want the buffered flag; use entryTypes (plural) for a quick multi-type subscription. You cannot mix the two in one observe() call.
The buffered: true flag is the detail that makes real-user monitoring possible. Some of the entries you care about — the first paint, the largest contentful paint — happen before your script even runs. Without buffering you’d miss them entirely. With it, the browser replays the entries already sitting in its buffer the first time your callback fires.
Watching the metrics that matter
The entry types map onto the vitals you already track. A few worth knowing:
paint—first-paintandfirst-contentful-painttimings.largest-contentful-paint— updates as bigger elements render; the last one before user interaction is your LCP. The entry carries anelementreference so you can see what was the largest paint.layout-shift— each unexpected shift, with avaluescore and ahadRecentInputflag. Sum the ones without recent input to compute CLS.longtask— any task that blocked the main thread for over 50ms, the raw material of poor responsiveness.eventandfirst-input— interaction latency, feeding INP.
A compact real-user LCP reporter:
let lcp = 0;
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
lcp = entry.startTime; // keep the latest
}
}).observe({ type: 'largest-contentful-paint', buffered: true });
// LCP is finalized on the first interaction or when the page is hidden
addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') sendToAnalytics({ lcp });
}, { once: true });
And a cumulative layout-shift accumulator, skipping shifts the user caused themselves:
let cls = 0;
new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!entry.hadRecentInput) cls += entry.value;
}
}).observe({ type: 'layout-shift', buffered: true });
The common thread
Step back and the three APIs rhyme. Each is a subscription: construct with a callback, observe() a target (a DOM node, or an entry type), receive batched entries asynchronously, and disconnect() when done. The batching is the whole point — the browser coalesces many underlying changes into one callback, and runs it off the path that would otherwise stutter your scrolling and rendering.
| IntersectionObserver | ResizeObserver | PerformanceObserver | |
|---|---|---|---|
| observe() | element, +options at construct | element, { box } | { type, buffered } |
| unobserve() | yes | yes | — |
| disconnect() | yes | yes | yes |
| takeRecords() | yes | — | yes |
This is the same family as MutationObserver, which watches the DOM tree for structural changes — four observers, one mental model.
Summary
- The observer pattern replaced
scroll/resizepolling: register interest once, get batched, asynchronous callbacks off the main interaction path. - IntersectionObserver answers “is it visible, and how much?” Tune it with
root,rootMargin(fire early or late), andthreshold(ratios). Readentry.isIntersectingandentry.intersectionRatio; remember thresholds fire on the way out too. Use one observer for many targets andunobserve()one-shot work like lazy loading. - ResizeObserver watches an element’s box, not the window. Pick
content-box,border-box, ordevice-pixel-content-box; readcontentRector the logicalinlineSize/blockSizesizes. Never synchronously resize an observed element inside the callback — defer torequestAnimationFrameto avoid the resize-loop warning. - PerformanceObserver streams timing entries —
paint,largest-contentful-paint,layout-shift,longtask,event. Subscribe bytypewithbuffered: trueto catch entries that fired before your script ran, calllist.getEntries(), and feature-detect withsupportedEntryTypes. - All four browser observers — including
MutationObserver— share the same lifecycle: construct,observe, receive entries,disconnect.