Web Platform · DOM and Events

Resize and Intersection Observers: Delivery Inside the Rendering Update

Both observers report geometry without polling, but they run at different points in the frame, and resize delivery can force a second layout that intersection delivery never causes.

Measuring an element by reading getBoundingClientRect in a loop is a well-known way to destroy frame time, because each read after a write forces layout. The observer APIs exist to answer the same questions without polling and without forcing layout at arbitrary points in the script: the browser already knows the geometry, and it hands over the results once per frame, at a moment it chooses. The two observers share that shape and almost nothing else. Resize observations are produced by layout and can cause another layout; intersection observations are produced by comparing geometry against a root rectangle and never change it.

ResizeObserver reports a box, not an element

observe(target, options) with box set to content-box, border-box, or device-pixel-content-box selects which rectangle is reported. The default is content-box, which excludes padding and border and matches the box that a child’s percentage sizes resolve against. Each entry carries contentRect as a DOMRectReadOnly and borderBoxSize and contentBoxSize as arrays of ResizeObserverSize objects with inlineSize and blockSize. The arrays rather than scalars exist because fragmentation can produce more than one fragment for an element, and because inline and block are writing-mode relative while width and height are physical.

device-pixel-content-box reports the box in physical pixels rather than CSS pixels. That distinction matters on fractional device pixel ratios, where a CSS-pixel measurement can be half a device pixel off and produce a canvas backing store that is blurred by resampling.

One observer can watch many targets. The callback receives all entries that changed since the last delivery, in observation order, and runs once per delivery rather than once per element. unobserve stops a single target; disconnect stops all of them and clears pending entries.

Delivery happens after layout

Resize observations are gathered after the browser has computed layout for the frame. The callback runs before paint, so a class or style applied inside it is reflected in the frame that is about to be drawn. This is the property that makes the API usable for responsive components: a component can react to its own width and adjust its rendering without a visible intermediate frame at the wrong size.

The placement has a cost that no other observer has. Changing layout inside the callback invalidates the layout that was just computed, so the browser must lay out again. If the second layout changes another observed element’s size, the observer runs again in the same frame. The update does not repeat without bound: delivery proceeds in passes ordered by node depth, targets that the current pass does not cover are recorded as skipped, and if any are still skipped when the loop ends the browser reports the error “ResizeObserver loop completed with undelivered notifications”.

const ro = new ResizeObserver((entries) => {
  for (const entry of entries) {
    const inline = entry.contentBoxSize[0].inlineSize;
    // setting a style here invalidates layout and may re-enter this callback
    entry.target.style.setProperty('--available', `${inline}px`);
  }
});

ro.observe(panel, { box: 'content-box' });

The loop is not a bug in the API; it is the honest consequence of letting script respond to layout. It becomes a defect when the callback’s write changes the size of the observed element itself, which makes each pass produce a new size. Writing a custom property that descendants consume is safe as long as the observed element’s own box does not depend on those descendants’ sizes; setting width on the observed element is not.

Reading geometry inside the callback is free in the sense that layout is already up to date, which makes the callback a good place for measurement and a bad place for anything that writes to a different observed element’s box.

IntersectionObserver measures against a root rectangle

observe on an IntersectionObserver tracks whether a target overlaps a root, which defaults to the viewport. Options select a different root element, a rootMargin that expands or shrinks the root’s rectangle, and a threshold value, or array of values, between 0 and 1 at which the callback should fire. With no threshold list, the default is [0], so the callback fires when visibility changes from none to some or back.

Each entry reports intersectionRatio, the fraction of the target’s area that overlaps the root; isIntersecting; boundingClientRect; intersectionRect; and rootBounds. Edge contact counts: a target whose edge touches the root boundary can be reported as intersecting while its ratio is 0, because zero-area overlap is still overlap. Code that treats intersectionRatio > 0 as the visibility test therefore disagrees with the API at exactly the boundary, which is where lazy-loading and ad-viewability logic tend to operate.

The initial observation always produces an entry, even when the target is entirely outside the root. This is the one guaranteed delivery, and it is what allows an observer to establish a baseline without a separate measurement. Subsequent entries are delivered when a threshold is crossed in either direction or the intersecting state flips, so a target that stays fully visible across many frames produces nothing after the first entry.

Where the two delivery points sit relative to each other

The two observers sit at different points in the rendering update, and only one of them delivers its callback there. Animation frame callbacks run first. Layout follows, and resize observations are gathered inside the same update, so resize callbacks run after layout and before paint; a callback that writes to layout forces another pass of that loop. Intersection observations are computed near the end of the update, after resize delivery, but the entries they produce are only queued: the callback runs from a separate task on the intersection observer task source. The frame’s sequence is frame callbacks, layout, resize callbacks, intersection computation, paint, and the intersection callback arrives after the update has finished.

That split has consequences worth designing around. Work that must happen before layout belongs in the frame callback. Work that depends on final sizes belongs in the resize callback, at the cost of possibly re-triggering layout. Work that only needs to know whether something entered the viewport fits the intersection callback, because a delay of one task does not matter for lazy loading or for starting an animation that is not pixel-synchronised with layout.

None of the three is synchronised with scrolling. Scroll steps run earlier in the same update, so an intersection entry describes geometry from the point at which it was computed, and by the time the callback task runs the page may have scrolled further. Code that positions an overlay to follow the scroll must compute from the scroll offset in the frame callback, not wait for an intersection entry.

Frames that are never produced

Both observers are tied to the rendering update. A document that is not being rendered, such as a background tab or a fully occluded window, does not run that update, so callbacks are not delivered for those frames regardless of what changed. Work that must proceed while a document is hidden cannot depend on observer delivery; it needs a task source that is not tied to rendering.

Each entry also carries a time field, a high-resolution timestamp for the moment the observation was computed. Because computation happens once per frame, several changes between two frames collapse into a single entry with a single timestamp, and the entry describes the state at computation time rather than the sequence of changes that led to it. A consumer that needs intermediate states must sample them itself, from scroll or resize events, rather than infer them from observer entries.

Observation cost and the failure modes to expect

An IntersectionObserver with a single root and many targets performs one geometric computation per target per frame while any of them could be intersecting. Browsers optimise aggressively, but the cost still scales with the number of observed targets, and observing thousands of elements individually is worse than observing a handful of containers with margins chosen to trigger early, for the same reason that a single container plus a snapshot of its children beats re-querying every node. Unobserving targets once they have become visible, where the application allows it, keeps the working set proportional to what is still unknown.

A ResizeObserver can deliver an entry for a target that has just been removed from the document, because delivery happens after layout and the observation was still active when layout ran. Entry targets must therefore be checked for isConnected before use. Similarly, an element with no layout box, such as a display: none subtree, reports zero size for the observed box rather than being skipped, so a component that hides and shows itself receives a resize entry on each transition and must distinguish “hidden” from “collapsed to zero” by other means.

Both observers survive their target being moved in the document: observation is on the node, not on its position, and a moved node keeps its subscription. That is not what a lifecycle which registers on mount and cleans up on unmount assumes, and it is why a component that drops its element without calling disconnect keeps receiving callbacks and keeps its closure alive. The shadow DOM boundary is not an obstacle either: geometry is observed in the flattened tree, so an element inside a shadow root can be observed from inside that root, and its reported sizes reflect the rendering, including any slotted content that contributes to its box.

References

  1. Resize Observer — CSS Working Group
  2. Intersection Observer — W3C
  3. CSSOM View Module — CSS Working Group