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, asgetBoundingClientRect()would return at computation time.intersectionRect— the visible part of the target within the root.rootBounds— the root rectangle afterrootMargin(ornullfor 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.
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
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
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:
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);
});
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.topno 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
ResizeObserveron 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.
Verification Steps
- Record a trace and confirm no Layout blocks nested inside observer callbacks.
- Search callbacks for
getBoundingClientRect,offsetTop,offsetWidth,clientHeightand justify each remaining one. - Test with a negative
rootMarginto confirm comparisons userootBounds, not0orinnerHeight. - Test inside a cross-origin iframe where
rootBoundsisnulland 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.topwith 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.
Related
- Batching DOM Reads and Writes in Observer Callbacks — ordering reads and writes
- Avoiding getComputedStyle in Hot Observer Paths — the style equivalent
- Spotting Forced Reflow Inside ResizeObserver Callbacks — finding the offending reads
↑ Back to DOM Query Minimization