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 — getBoundingClientRect is viewport-relative, so every scroll changes every rectangle.
  • A font loads, an image arrives, or a sibling reflows.

Three Sources for the Same RectangleThree ways to obtain a target's rectangle inside a callback. The entry's own boundingClientRect is free and correct for the frame that produced the callback. A fresh getBoundingClientRect call is correct now but may force a layout. A cached rectangle from an earlier frame is free but silently wrong after any scroll or reflow.entry.boundingClientRectfree, and correct for the frame that produced this callback. The right answer in almost every case.el.getBoundingClientRect()correct now, but forces layout if anything has been invalidated — which, mid-callback, it usually has.cache.get(el)free, and silently wrong after any scroll, resize, font load or sibling reflow.

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

TypeScript
// 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:

TypeScript
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:

TypeScript
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:

TypeScript
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:

TypeScript
let dragOrigin: DOMRectReadOnly | null = null;
const invalidate = () => { dragOrigin = null; };
addEventListener('scroll', invalidate, { passive: true, capture: true });
new ResizeObserver(invalidate).observe(document.documentElement);

Frame-Scoped Caching Cannot Go StaleTwo caches across three frames with a scroll between the second and third. The unscoped cache returns the frame-one rectangle in frame three, which is wrong by the scroll distance. The frame-scoped cache sees a new frame identifier and re-reads, so it returns a correct rectangle while still performing only one read per frame.unscoped Map cacheframe 1 — read, storeframe 2 — cache hitscrollframe 3 — cache hit, wrong by the scroll distanceframe-scoped WeakMap cacheframe 1 — read, storeframe 2 — re-read oncescrollframe 3 — new frame id, re-read, correctThe saving is within a frame, which is where the N-reads-per-batch problem lives. Across frames there is nothing to save.

Verification Steps

  • Search the callback for getBoundingClientRect. Every occurrence on entry.target is 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 Map would.

Which Rectangle Each Entry Already CarriesA field reference for the two entry types. An IntersectionObserverEntry carries the target’s bounding rectangle, the intersection rectangle and the root bounds. A ResizeObserverEntry carries the content rectangle and the box-size arrays. All are free to read inside the callback.Free to read, already computed by the layout pass that called youIntersectionObserverEntryboundingClientRectintersectionRectrootBoundsResizeObserverEntrycontentRectcontentBoxSize / borderBoxSizedevicePixelContentBoxSize

Common Mistakes to Avoid

  • Calling getBoundingClientRect() on entry.target. The rectangle is already on the entry.
  • Caching across frames. Scroll alone invalidates every viewport-relative rectangle.
  • Using a Map for 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.


↑ Back to DOM Query Minimization