Caching a rectangle is a reasonable-sounding optimisation that is usually unnecessary and occasionally wrong. The first question is not how to cache it but why it is being read at all.
Problem / Scenario Context
A profile shows a callback spending most of its time in forced layout. The callback loops over entries and, for each one, calls getBoundingClientRect() on the target to decide where to position an overlay.
The obvious fix is to cache the rectangles in a Map and reuse them. It works, the forced layouts disappear, and three weeks later overlays start appearing in the wrong place after the sidebar is collapsed — because the cache was never invalidated and the elements have all moved.
The less obvious fix is that the rectangle was already available. IntersectionObserverEntry carries boundingClientRect, intersectionRect and rootBounds; ResizeObserverEntry carries contentRect. All were computed by the browser during the layout pass that triggered the callback, and reading them costs nothing.
Mechanics Explanation
getBoundingClientRect() returns a live measurement. If any style has been invalidated since the last layout, the browser must recompute layout before it can answer — a forced synchronous layout. Inside a callback that also writes, that is the read-write-read pattern, and it is why the profile looks the way it does.
The entry's rectangles are snapshots taken during the layout pass that produced the callback. They are free to read, and they are correct for that moment. That last point is the whole story on caching: a rectangle is only valid until the next layout change, so a cache is a bet that nothing will move before you read it again.
Three things invalidate that bet, and only the first is obvious:
- The element or an ancestor changes size or position.
- The page scrolls —
getBoundingClientRectis viewport-relative, so every scroll changes every rectangle. - A font loads, an image arrives, or a sibling reflows.
Comparison Table: When a Cache Is Justified
| Situation | Read from | Why |
|---|---|---|
| The target's own rectangle, in its callback | entry.boundingClientRect |
already computed, free |
| The root's rectangle | entry.rootBounds |
same |
| The overlap region | entry.intersectionRect |
same |
| A different element's rectangle | getBoundingClientRect(), batched |
not on the entry |
| The same other element, many times per frame | a per-frame cache | one read amortised over many uses |
| Positions across frames | do not cache | scroll alone invalidates everything |
The fifth row is the only case where caching genuinely earns its keep: several entries in one batch all need the same container's rectangle, and reading it once per frame instead of once per entry turns N forced layouts into one.
Minimal Reproducible Example
// Redundant: the rectangle is already on the entry
const io = new IntersectionObserver((entries) => {
for (const entry of entries) {
const rect = entry.target.getBoundingClientRect(); // forces layout
position(overlayFor(entry.target), rect);
}
});
Production-Safe Solution
Start by not reading at all:
const io = new IntersectionObserver((entries) => {
for (const entry of entries) {
position(overlayFor(entry.target), entry.boundingClientRect); // free
}
});
When a rectangle genuinely is needed for an element that is not the target, cache it for one frame only, keyed by a frame counter so staleness is structurally impossible:
let frameId = 0;
requestAnimationFrame(function tick() { frameId += 1; requestAnimationFrame(tick); });
const rectCache = new WeakMap<Element, { frame: number; rect: DOMRectReadOnly }>();
function rectOnce(el: Element): DOMRectReadOnly {
const hit = rectCache.get(el);
if (hit && hit.frame === frameId) return hit.rect; // same frame — reuse
const rect = el.getBoundingClientRect(); // at most once per frame
rectCache.set(el, { frame: frameId, rect });
return rect;
}
Two properties make this safe where a plain Map cache is not.
It cannot go stale across frames. Any layout change happens between frames, and the frame counter has moved, so the next read is fresh. There is no invalidation logic to forget.
It uses a WeakMap. A cache keyed by element in a plain Map retains every element it has ever seen, which is the registry leak in a new costume — and a particularly easy one to miss, because a cache does not look like a registry.
Then batch, so the reads and the writes do not interleave:
const io = new IntersectionObserver((entries) => {
const container = document.querySelector('.board')!;
// pass 1 — read only
const plan = entries.map((entry) => ({
el: entry.target,
rect: entry.boundingClientRect, // free
origin: rectOnce(container), // one read for the whole batch
}));
// pass 2 — write only
for (const { el, rect, origin } of plan) {
positionOverlay(el, rect.top - origin.top, rect.left - origin.left);
}
});
If a rectangle really must survive across frames — a drag operation that captures a start position, say — store it deliberately with an explicit lifetime, and invalidate it on scroll and resize rather than hoping:
let dragOrigin: DOMRectReadOnly | null = null;
const invalidate = () => { dragOrigin = null; };
addEventListener('scroll', invalidate, { passive: true, capture: true });
new ResizeObserver(invalidate).observe(document.documentElement);
Verification Steps
- Search the callback for
getBoundingClientRect. Every occurrence onentry.targetis redundant. - Record a profile and count forced layouts. The batched version should produce one per frame at most.
- Scroll and check positions — an overlay that drifts by exactly the scroll distance is reading a stale cached rectangle.
- Take a heap snapshot and confirm the cache is not retaining detached elements, which a plain
Mapwould.
Common Mistakes to Avoid
- Calling
getBoundingClientRect()onentry.target. The rectangle is already on the entry. - Caching across frames. Scroll alone invalidates every viewport-relative rectangle.
- Using a
Mapfor the cache. It retains every element it has seen, which is a leak that does not look like one. - Caching and then writing between reads. The cache hides the forced layout without removing the interleave; batch the passes properly.
FAQ
Is caching getBoundingClientRect a good idea?
Rarely, and only within a single frame. Across frames the value is invalidated by scrolling alone, since the rectangle is viewport-relative, so a cache that survives a frame is a source of silently wrong positions rather than an optimisation.
Why is the entry's rectangle free when a fresh call is not?
Because the entry's rectangles were computed during the layout pass that produced the callback. Reading them is a property access. A fresh getBoundingClientRect call asks for a live measurement, which obliges the browser to flush any pending layout first.
When does a per-frame cache actually help?
When several entries in one batch all need the rectangle of the same other element — a container or an offset parent. Reading it once per frame instead of once per entry turns many forced layouts into one, which is a real saving on a large batch.
Why use a WeakMap rather than a Map for the cache?
Because a Map keyed by element keeps every element it has ever cached alive for the life of the page. A cache does not look like a registry, which makes this leak particularly easy to miss in review, and a WeakMap removes the possibility entirely.
Related
- Batching DOM Reads and Writes in Observer Callbacks — the two-pass structure this fits into
- DOM Query Minimization — which reads flush layout and which are free
- WeakMap vs Map for Observer Target Tracking — why the cache must not use a Map
↑ Back to DOM Query Minimization