Inside observer callbacks, read geometry from the entry — boundingClientRect, intersectionRect and rootBounds on intersection entries, contentBoxSize and borderBoxSize on resize entries — rather than calling getBoundingClientRect() or offsetWidth again; the entry's values were computed with the layout the browser already did, and re-querying can force a new one.

Problem / Scenario Context

A sticky table-of-contents component receives intersection entries for the article's headings and, in the callback, calls heading.getBoundingClientRect() on each entry's target to decide which heading is nearest the top. A separate analytics tag, sharing the same callback batch, adds a class to each visible heading. Profiles show a forced layout for every heading in every batch: the analytics write invalidates layout, and each subsequent getBoundingClientRect() recomputes it.

The geometry the component wanted was already on the entries. The DOM Query Minimization topic covers the general problem; this page is about the data the observers hand you for free.

Mechanics Explanation

When the browser computes intersections during the rendering steps, it already knows every target's position. It stores three rectangles on each IntersectionObserverEntry:

  • boundingClientRect — the target's border box in viewport coordinates, as getBoundingClientRect() would return at computation time.
  • intersectionRect — the visible part of the target within the root.
  • rootBounds — the root rectangle after rootMargin (or null for cross-origin iframes).

ResizeObserverEntry similarly carries contentRect, contentBoxSize, borderBoxSize and devicePixelContentBoxSize, computed after layout.

These are snapshots: plain data captured at entry.time. Reading them never touches layout. Calling getBoundingClientRect() in the callback, by contrast, returns current geometry — which is equal to the snapshot if nothing changed, and requires a fresh layout if anything has invalidated it since. The callback runs in a task after paint (intersection) or inside the rendering steps (resize); any write before the read — yours or another callback's in the same batch — makes the read force layout.

Entry Snapshot Versus Live QueryTwo columns. The entry snapshot is plain data computed during the browser's own layout, costs nothing to read, is consistent across all entries in the batch, and reflects the moment of the crossing. A live getBoundingClientRect call returns current geometry, is free only if layout is clean, forces a synchronous layout after any write, and may reflect changes made after the crossing.entry.boundingClientRectCaptured during the browser's own layoutReading it never forces layoutConsistent across the whole batchAs of the crossing (entry.time)el.getBoundingClientRect()Current geometry, not the crossingFree only while layout is cleanForces layout after any writeCan disagree between entries

Comparison Table: What the Entry Already Has

You need Instead of Read from the entry
Target's top relative to the viewport el.getBoundingClientRect().top entry.boundingClientRect.top
Target's rendered size (intersection) el.offsetWidth/Height entry.boundingClientRect.width/height
How much is visible manual clipping maths entry.intersectionRect / intersectionRatio
Root/viewport size with margin innerHeight, root getBoundingClientRect() entry.rootBounds
Element size (resize) el.offsetWidth, clientWidth contentBoxSize[0], borderBoxSize[0]
Device-pixel size (resize) clientWidth × devicePixelRatio devicePixelContentBoxSize[0]
Position within a scroller not on entries: query once, then cache

Minimal Reproducible Example

TypeScript
const io = new IntersectionObserver((entries) => {
  for (const e of entries) {
    e.target.classList.toggle('in-view', e.isIntersecting);          // write
  }
  // Nearest heading to the top — re-querying after writes: one forced layout per heading.
  const nearest = entries
    .filter((e) => e.isIntersecting)
    .map((e) => ({ el: e.target, top: e.target.getBoundingClientRect().top }))
    .sort((a, b) => Math.abs(a.top) - Math.abs(b.top))[0];
  if (nearest) setActive(nearest.el.id);
}, { rootMargin: '0px 0px -60% 0px' });

declare function setActive(id: string): void;

A trace shows a purple Layout nested in the callback for each visible heading.

Production-Safe Solution

TypeScript
const io = new IntersectionObserver((entries) => {
  // Read phase: everything from the entries, no DOM queries.
  const visible = entries
    .filter((e) => e.isIntersecting)
    .map((e) => ({ id: e.target.id, top: e.boundingClientRect.top, rootTop: e.rootBounds?.top ?? 0 }));

  // Write phase.
  for (const e of entries) e.target.classList.toggle('in-view', e.isIntersecting);

  const nearest = visible.sort((a, b) => Math.abs(a.top - a.rootTop) - Math.abs(b.top - b.rootTop))[0];
  if (nearest) setActive(nearest.id);
}, { rootMargin: '0px 0px -60% 0px' });

The callback now does no layout work at all. The "nearest to the top" comparison uses rootBounds.top so it stays correct when a negative rootMargin or a scroll-container root moves the reference line.

One subtlety: entries only arrive for targets that crossed a threshold. The batch does not include headings that remained visible without crossing. If the decision needs every currently visible target, keep a Map from target to its latest entry and update it on each callback — then decide from the map, still without querying:

TypeScript
const latest = new Map<Element, IntersectionObserverEntry>();
const io2 = new IntersectionObserver((entries) => {
  for (const e of entries) e.isIntersecting ? latest.set(e.target, e) : latest.delete(e.target);
  const nearest = [...latest.values()].sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)[0];
  if (nearest) setActive(nearest.target.id);
});

Forced Layouts per Callback BatchA bar chart of forced layouts per callback batch for a table of contents with twelve visible headings. Re-querying getBoundingClientRect after class writes forced twelve layouts. Reading before writing forced one. Reading from entry.boundingClientRect forced none.Table of contents, 12 headings in a batchquery after writes12 forced layoutsquery, reads before writes1entry.boundingClientRect0

When the Snapshot Is Not Enough

Entry geometry is a snapshot from entry.time. There are legitimate reasons to query live geometry anyway:

  • Stale entries. In the latest-entry map above, an entry for a heading that has stayed visible may be seconds old; its boundingClientRect.top no longer matches the scroll position. For "which is nearest now", query live — but once, for the one candidate you need, and before writing anything.
  • Positions relative to something else. Entries give viewport (or root) coordinates. Offsets within a scroll container's content, or relative to another element, need a query; cache the result and invalidate it with a ResizeObserver on the container, as in caching getBoundingClientRect results in observer callbacks.
  • Other elements. Entries describe only their target. Measuring a sibling or a tooltip needs a query.

The rule of thumb: if the question is about the target, at the moment it crossed, the entry has the answer; otherwise query once, read before you write, and cache.

Entry Data or a Live Query?A decision chain. If the question is about the target at the moment it crossed, read the entry's rectangles. Otherwise, if it is about the target now and the entry may be stale, query live once before any writes. Otherwise, if it is about a different element or a different coordinate space, query once and cache, invalidating with a ResizeObserver. Otherwise the entry suffices.About the target, at its crossing?Read boundingClientRect / intersectionRect /rootBoundsyesnoAbout the target now, entry possibly stale?Query once, before any writesyesnoAbout another element or coordinate space?Query once, cache, invalidate via ResizeObserveryesnoThe entry has what you need.

Verification Steps

  • Record a trace and confirm no Layout blocks nested inside observer callbacks.
  • Search callbacks for getBoundingClientRect, offsetTop, offsetWidth, clientHeight and justify each remaining one.
  • Test with a negative rootMargin to confirm comparisons use rootBounds, not 0 or innerHeight.
  • Test inside a cross-origin iframe where rootBounds is null and confirm a sensible fallback.
  • Compare behaviour of old and new callbacks on fast scrolls to confirm identical results.

Common Mistakes to Avoid

  • Re-querying the target in the callback. Its geometry is on the entry.
  • Comparing boundingClientRect.top with 0 when the root has a margin or is a container.
  • Treating old entries as current in long-lived maps.
  • Mixing reads and writes when a query is truly needed.

FAQ

Is entry.boundingClientRect exactly what getBoundingClientRect would return?

It is the target's border box in the same coordinate space, captured when the intersection was computed. If nothing moved since, the two are equal; if the page scrolled or changed after that moment, the live call reflects the new state.

Why is rootBounds sometimes null?

For targets inside cross-origin iframes observed with the implicit root, the browser does not expose the root rectangle, to avoid leaking information about the embedding page.

Do ResizeObserver entries include position?

No. They carry sizes only. contentRect's x and y are padding offsets, not page positions. Query position separately if you need it.

Does reading entry rectangles allocate?

The rectangles are created with the entry, so reading them is just property access. Avoid keeping thousands of old entries around, though, because each holds three rectangle objects.

Is one getBoundingClientRect call per batch acceptable?

Yes, if it happens before any writes in the callback. Layout is clean when observer callbacks start, so the first read is cheap. It is the read after a write that forces a new layout.

Can I use intersectionRect to position a tooltip?

Yes, as long as the tooltip is positioned in the same coordinate space. For a fixed-position tooltip, intersectionRect's viewport coordinates can be used directly.


↑ Back to DOM Query Minimization