The fastest DOM read inside an observer callback is the one you never make, because the entry object already contains it.

The Forced Synchronous Layout Problem

IntersectionObserver and ResizeObserver callbacks are scheduled by the browser to run after layout has been computed for the current rendering update — a timing guarantee explained in depth in DOM Query Minimization. That guarantee only holds for values the browser itself hands you through the entry object. The moment your callback calls getBoundingClientRect(), reads offsetHeight, or calls getComputedStyle() on a different query than what the entry already describes, the browser cannot trust its cached layout tree anymore — because your callback may have already written something that invalidated it — and it must run layout again, synchronously, right there in your callback.

This is expensive precisely because observer callbacks often process many entries in a single invocation. A pattern that costs one extra layout for a single element becomes tens or hundreds of extra layouts when applied inside a .forEach() over a batch of IntersectionObserverEntry or ResizeObserverEntry objects — a data grid with 200 visible rows, or a virtualized list reporting resize on every visible card.

The Read-Write-Read Anti-Pattern

The classic thrashing shape looks like this: read a layout property, write a style based on it, then read again — either the same property or a different one that the write just invalidated. Each read-after-write pair forces a synchronous layout because the browser must flush the pending style change before it can answer the next read accurately.

Read-write-read thrashing vs. batched read-then-write Two flows. The top flow shows three elements processed one at a time, each doing a read, a write, and another read, forcing three separate layout passes. The bottom flow shows all reads collected first from entry data, then all writes applied in one requestAnimationFrame callback, forcing at most one layout pass. Interleaved read-write-read (thrashing) read h1 layout write h1 layout read h2 layout write h2 layout read h3 layout write h3 layout 3 elements → 3 forced layout passes Batched read-phase then write-phase read h1, h2, h3 from entries requestAnimationFrame write h1, h2, h3 1 layout 3 elements → 1 forced layout pass, deferred to next frame

Minimal Reproducible Example

Bad — re-queries layout properties inside the callback loop

TypeScript
// BAD — read-write-read thrashing across a batch of ResizeObserver entries
const observer = new ResizeObserver((entries: ResizeObserverEntry[]) => {
  entries.forEach((entry) => {
    const el = entry.target as HTMLElement;
    const currentHeight = el.getBoundingClientRect().height; // read — forces layout if dirty
    el.style.setProperty("--card-height", `${currentHeight}px`); // write — invalidates layout
    const updatedWidth = el.offsetWidth; // read again — forces layout again, for every element
    console.log(updatedWidth);
  });
});
JavaScript
// Plain JS equivalent of the bad pattern
const observer = new ResizeObserver((entries) => {
  entries.forEach((entry) => {
    const el = entry.target;
    const currentHeight = el.getBoundingClientRect().height;
    el.style.setProperty('--card-height', `${currentHeight}px`);
    const updatedWidth = el.offsetWidth;
    console.log(updatedWidth);
  });
});

With 50 observed cards resizing simultaneously (a common case when a parent flex container reflows), this pattern forces up to 100 separate synchronous layout passes in one callback invocation — measurable as a wall of purple "Layout" blocks in the DevTools Performance panel, each attributed to this callback's call stack.

Good — read from entry data, batch writes into one rAF

TypeScript
// GOOD — read exclusively from entry data, batch all writes into one rAF callback
interface CardMetrics {
  el: HTMLElement;
  height: number;
  width: number;
}

const observer = new ResizeObserver((entries: ResizeObserverEntry[]) => {
  // Phase 1: reads — entirely free, no layout triggered. The entry already
  // carries the settled post-layout dimensions.
  const metrics: CardMetrics[] = entries.map((entry) => {
    const [size] = entry.contentBoxSize;
    return {
      el: entry.target as HTMLElement,
      height: size.blockSize,
      width: size.inlineSize,
    };
  });

  // Phase 2: writes — deferred to the next frame, applied in one batch.
  requestAnimationFrame(() => {
    metrics.forEach(({ el, height, width }) => {
      el.style.setProperty("--card-height", `${height}px`);
      el.style.setProperty("--card-width", `${width}px`);
    });
  });
});
JavaScript
// Plain JS equivalent of the good pattern
const observer = new ResizeObserver((entries) => {
  const metrics = entries.map((entry) => {
    const [size] = entry.contentBoxSize;
    return { el: entry.target, height: size.blockSize, width: size.inlineSize };
  });

  requestAnimationFrame(() => {
    metrics.forEach(({ el, height, width }) => {
      el.style.setProperty('--card-height', `${height}px`);
      el.style.setProperty('--card-width', `${width}px`);
    });
  });
});

The fixed version never touches getBoundingClientRect(), offsetHeight, or offsetWidth inside the callback at all — every dimension comes from entry.contentBoxSize, which the browser already computed as part of its normal layout pass. This is the same "prefer entry data over live DOM queries" discipline covered in Core Observer Fundamentals & Browser APIs.

The Same Pattern for IntersectionObserver

IntersectionObserverEntry carries an equivalent set of free measurements — boundingClientRect, intersectionRect, and intersectionRatio — that eliminate the need to re-query the target's position or size when deciding how to respond to a visibility change.

TypeScript
interface VisibilityUpdate {
  el: Element;
  opacity: number;
  translateY: number;
}

const io = new IntersectionObserver((entries: IntersectionObserverEntry[]) => {
  // Phase 1: read — everything comes from the entry, zero forced layout
  const updates: VisibilityUpdate[] = entries.map((entry) => ({
    el: entry.target,
    opacity: entry.intersectionRatio,
    translateY: entry.isIntersecting ? 0 : 12,
  }));

  // Phase 2: write — batched into one rAF callback
  requestAnimationFrame(() => {
    updates.forEach(({ el, opacity, translateY }) => {
      const target = el as HTMLElement;
      target.style.opacity = String(opacity);
      target.style.transform = `translateY(${translateY}px)`;
    });
  });
}, { threshold: [0, 0.25, 0.5, 0.75, 1] });

Pairing this with syncing observer callbacks with requestAnimationFrame keeps every visual mutation triggered by intersection changes aligned to the paint cycle rather than applied piecemeal as entries stream in.

Why This Also Prevents the ResizeObserver Loop Warning

Deferring writes to requestAnimationFrame is not just an optimization here — for ResizeObserver specifically, it is also the standard fix for ResizeObserver loop limit exceeded. A synchronous write inside the callback that changes an observed element's own box size can re-trigger the same observer within the same rendering update; moving that write to the next frame breaks the cycle. Batching reads-then-writes and deferring writes to rAF are, in practice, the same discipline applied for two different reasons — one for raw performance, one to avoid the loop-detection warning entirely.

Common Mistakes to Avoid

  • Calling getComputedStyle() inside the loop for a value the entry already provides. getComputedStyle() forces a full style recalculation, not just layout — more expensive than getBoundingClientRect(). If you need border-box dimensions, ask ResizeObserver for them via { box: 'border-box' } rather than resolving them by hand.
  • Writing inside the same .forEach() iteration as the read. Even if each individual read looks harmless, interleaving reads and writes per element defeats batching entirely — the browser cannot tell you intend to batch later.
  • Forgetting that requestAnimationFrame callbacks can still thrash if they read again. The write-phase callback must only write. If it reads a layout property from the DOM (rather than from the captured metrics array), it reintroduces the exact problem batching was meant to solve.
  • Assuming this only matters for large lists. Even a single observed element benefits from reading its dimensions once from the entry rather than issuing a redundant getBoundingClientRect() call — the entry's data is free; the manual query is not.

FAQ

Why is re-querying getBoundingClientRect inside an observer callback wasteful?

Both IntersectionObserverEntry and ResizeObserverEntry already carry pre-computed layout geometry — boundingClientRect, intersectionRect, and contentRect — captured during the browser's own layout pass. Calling getBoundingClientRect() again inside the callback forces a fresh synchronous layout to guarantee an up-to-date value, even though the entry already holds an equivalent, free measurement.

What is the read-write-read anti-pattern?

It is a loop or sequence where a layout read (like offsetHeight) is followed by a DOM write (like setting style.height) and then another layout read, repeated per element. Each read after a write forces the browser to flush pending style changes and re-run layout immediately, turning what should be one layout pass per frame into one layout pass per element.

Does batching reads and writes help even if I only have one observed element?

Yes, though the benefit is smaller with a single element. The bigger win with one element is simply never re-querying layout properties that the entry already provides. Batching becomes essential once a callback processes many entries in one invocation, since an interleaved read/write per element multiplies the number of forced layouts by the number of elements.

Should writes always be deferred to requestAnimationFrame?

For ResizeObserver callbacks, yes, whenever the write could affect an observed element's own box — this also avoids the loop-limit warning. For IntersectionObserver callbacks writing to unrelated elements, deferring to rAF is still good practice for frame-aligned visual updates, but it is not strictly required to avoid a loop error the way it is for ResizeObserver.


↑ Back to DOM Query Minimization