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.

scroll eventmain threadobserver callbackthreshold crossedthreshold crossed
Scroll listeners fire on every pixel of movement; an observer fires once, only when a threshold is actually crossed.

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 is null, 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; when root is 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.0 fires only when the whole element is inside; [0, 0.25, 0.5, 0.75, 1] fires at every quarter.
viewport (root)rootMargin: 24px 40pxtargetfires herereal edge
rootMargin inflates the root box (dashed) so the callback fires before the target reaches the real viewport edge.

Each entry, field by field

An IntersectionObserverEntry is a read-only snapshot. The ones you reach for most:

  • isIntersectingtrue if the target overlaps the root at all right now. The single most-used field.
  • intersectionRatio — how much of the target is visible, 0 to 1. 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 — a DOMHighResTimeStamp of 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.

interactiveIntersectionObserver: ratio as you scroll

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.

interactiveResizeObserver: element-driven breakpoint
parent (fixed)#panelcallback(entries)contentRect.width ↕.height
ResizeObserver reports the element's own box changing, independent of the window — the callback receives the new contentRect.

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 — a DOMRectReadOnly (with width, height, top, left). Convenient, and the oldest-supported field.
  • entry.contentBoxSize and entry.borderBoxSize — arrays of &#123; inlineSize, blockSize &#125; objects. inlineSize is width in horizontal writing modes, blockSize is 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.
content-boxpaddingborder-box (padding + border)border
The same element has three boxes; ResizeObserver can report any of them.

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.

Loop
resize fires
callback sets width
↻ re-triggers
Deferred
resize fires
rAF → next frame
write width (settled)
Writing a size inside the callback re-triggers the observer; deferring the write to the next frame breaks the loop.

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.

interactivePerformanceObserver: your own measures

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.

performance timelineFPFCPLCPlongtaskshiftobserve() calledbuffered replay →callback(list) — backlog + live
The browser records entries into a buffer as they happen; observing with buffered:true replays the backlog, then streams new entries live.

Watching the metrics that matter

The entry types map onto the vitals you already track. A few worth knowing:

  • paintfirst-paint and first-contentful-paint timings.
  • largest-contentful-paint — updates as bigger elements render; the last one before user interaction is your LCP. The entry carries an element reference so you can see what was the largest paint.
  • layout-shift — each unexpected shift, with a value score and a hadRecentInput flag. 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.
  • event and first-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.

new XObserver(cb)observe(target)cb(entries)disconnect()
Intersection: target = DOM node, entries = visibilityResize: target = DOM node, entries = box sizesPerformance: target = entry type, entries = timings
All three observers share one lifecycle: construct with a callback, observe targets, receive batched entries, disconnect.
IntersectionObserverResizeObserverPerformanceObserver
observe()element, +options at constructelement, { box }{ type, buffered }
unobserve()yesyes
disconnect()yesyesyes
takeRecords()yesyes
Method surface shared across the three observers.

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/resize polling: 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), and threshold (ratios). Read entry.isIntersecting and entry.intersectionRatio; remember thresholds fire on the way out too. Use one observer for many targets and unobserve() one-shot work like lazy loading.
  • ResizeObserver watches an element’s box, not the window. Pick content-box, border-box, or device-pixel-content-box; read contentRect or the logical inlineSize/blockSize sizes. Never synchronously resize an observed element inside the callback — defer to requestAnimationFrame to avoid the resize-loop warning.
  • PerformanceObserver streams timing entries — paint, largest-contentful-paint, layout-shift, longtask, event. Subscribe by type with buffered: true to catch entries that fired before your script ran, call list.getEntries(), and feature-detect with supportedEntryTypes.
  • All four browser observers — including MutationObserver — share the same lifecycle: construct, observe, receive entries, disconnect.