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.
Minimal Reproducible Example
Bad — re-queries layout properties inside the callback loop
// 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);
});
});
// 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
// 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`);
});
});
});
// 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.
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 thangetBoundingClientRect(). If you needborder-boxdimensions, askResizeObserverfor 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
requestAnimationFramecallbacks 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 capturedmetricsarray), 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.
Related
- DOM Query Minimization — parent guide on caching references and eliminating forced reflows
- Reducing Layout Thrashing with ResizeObserver — a worked data-grid example with before/after flame charts
- Syncing Observer Callbacks with requestAnimationFrame — frame-aligned write scheduling for IntersectionObserver
- Fixing 'ResizeObserver loop limit exceeded' — why the same rAF deferral also prevents this console warning
↑ Back to DOM Query Minimization