ResizeObserver combined with requestAnimationFrame batching eliminates forced synchronous layouts by decoupling dimension reads from style writes, so the browser never has to flush its layout queue mid-frame.
Problem and Scenario Context
Modern dashboards and responsive UIs frequently suffer from main-thread blocking when element dimensions change. The problem arises inside DOM Query Minimization pipelines where code alternates between reading layout properties — offsetWidth, getBoundingClientRect, clientHeight — and writing back to the DOM in the same synchronous block. Each read after a pending write forces the browser to immediately flush queued style changes and recompute geometry, blocking the main thread.
Legacy window.resize listeners make this worse. They fire at unpredictable rates (30–120 events per second during a drag), offer no native batching, and encourage direct DOM measurement without frame synchronization. The result is forced reflows stacking inside a single animation frame, dropping frame rate to below 30fps on mid-range hardware during a simple sidebar resize.
The fix is two-layered: replace the polling listener with ResizeObserver, then guard its callback behind requestAnimationFrame so that every geometry read and style write executes in the same rendering phase rather than interrupting it. For the complementary problem of too many observer instances firing simultaneously, see Callback Throttling & Debouncing.
Mechanics: Why Layout Thrashing Happens
The browser maintains an internal style/layout cache that is invalidated whenever JavaScript writes to the DOM. When code then immediately reads a layout property, the engine cannot return the cached value — the pending write might have changed it — so it performs a synchronous forced reflow: flushing all pending style mutations, running layout, and only then returning the measurement. This is sometimes called a "layout flush" in V8 profiler output.
ResizeObserver fires its callback during the browser's rendering update steps, specifically after layout calculation is complete but before paint. This placement is critical: the callback already has accurate contentRect values without triggering a forced reflow because the layout has already been computed. Reading entry.contentRect inside the callback is free. The danger is writing back to the DOM inside the same synchronous callback, which schedules another layout pass for the current frame. Deferring writes to a requestAnimationFrame call breaks the cycle by moving them to the next frame's write phase.
Comparison: Synchronous Read/Write vs. Batched Observer
| Approach | Forced reflows per resize event | Main-thread blocking | Frame budget impact |
|---|---|---|---|
window.resize + synchronous read/write |
1 per event (30–120/s) | Yes — synchronous flush | Severe, often 16ms+ per event |
ResizeObserver without rAF batching |
0 reads, but writes can cause re-layout | Partial — write triggers next layout | Moderate, depends on write volume |
ResizeObserver + requestAnimationFrame batching |
0 | No — writes deferred to paint phase | Negligible — one write pass per frame |
Cached reference + ResizeObserver + rAF |
0 | No | Minimal — single frame slot regardless of event count |
Minimal Reproducible Example
This snippet demonstrates the thrashing pattern in isolation. Open DevTools Performance, record while calling startBadResize() repeatedly, and you will see stacked Forced Reflow warnings in the flame chart.
// BAD: triggers a forced reflow on every call
const box = document.getElementById('resizable');
function startBadResize() {
window.addEventListener('resize', () => {
const height = box.offsetHeight; // forced layout flush
box.style.width = height * 1.5 + 'px'; // write after read = thrash
});
}
Production-Safe Solution
The implementation below is a self-contained, cleanup-aware ResizeObserver factory. It coalesces multiple observer callbacks into a single render frame using a requestAnimationFrame guard, ensuring geometry reads (contentRect) never interrupt the browser's paint cycle and style writes are always deferred to the correct rendering phase.
interface ResizeHandle {
disconnect: () => void;
signal: AbortSignal;
}
export function createResizeObserver(
target: Element,
callback: (rect: DOMRectReadOnly) => void
): ResizeHandle {
const controller = new AbortController();
let rafId: number | null = null;
let latestRect: DOMRectReadOnly | null = null;
const observer = new ResizeObserver((entries: ResizeObserverEntry[]) => {
const rect = entries[0]?.contentRect;
// Zero-dimension guard: hidden elements return 0×0; skip to avoid NaN downstream
if (!rect || rect.width === 0 || rect.height === 0) return;
latestRect = rect;
// rAF batching: only schedule one frame callback regardless of burst event count
if (rafId === null) {
rafId = requestAnimationFrame(() => {
if (latestRect) callback(latestRect);
rafId = null;
latestRect = null;
});
}
// If rafId is already set, we discard this entry — latestRect holds the most recent value
});
observer.observe(target);
const disconnect = (): void => {
if (rafId !== null) cancelAnimationFrame(rafId);
observer.unobserve(target);
observer.disconnect();
controller.abort();
rafId = null;
latestRect = null;
};
return { disconnect, signal: controller.signal };
}
// Plain JS equivalent (no type annotations):
// function createResizeObserver(target, callback) { ... }
React usage:
import { useEffect, useRef } from 'react';
import { createResizeObserver } from './createResizeObserver';
export function useResizeObserver(
ref: React.RefObject<Element>,
callback: (rect: DOMRectReadOnly) => void
) {
useEffect(() => {
if (!ref.current) return;
const { disconnect } = createResizeObserver(ref.current, callback);
return disconnect; // React cleanup: called on unmount or dependency change
}, [ref, callback]);
}
Vue 3 usage:
import { onMounted, onUnmounted, Ref } from 'vue';
import { createResizeObserver } from './createResizeObserver';
export function useResizeObserver(el: Ref<Element | null>, callback: (rect: DOMRectReadOnly) => void) {
let handle: ReturnType<typeof createResizeObserver> | null = null;
onMounted(() => { if (el.value) handle = createResizeObserver(el.value, callback); });
onUnmounted(() => handle?.disconnect());
}
Verification Steps
After applying this pattern, confirm it works correctly with these DevTools steps:
- Open Chrome DevTools → Performance panel. Enable Screenshots and Memory, then record a 5-second session while dragging the viewport edge or triggering element resize.
- In the flame chart, filter tasks by
Layout. Any task labelled Forced Reflow indicates a surviving synchronous read/write cycle — there should be zero after the fix. - Toggle Layout Shift Regions in the Rendering tab (three-dot menu → More tools → Rendering) to highlight synchronous geometry flushes in real time. The overlay should be empty during resize after the fix.
- Open the Memory tab → take a Heap Snapshot. Filter by Detached to confirm no detached DOM nodes referencing the observed element remain after calling
disconnect(). - In the Performance panel's Summary section, verify Layout task duration stays consistently below 4ms per frame and the frame rate remains at 60fps during continuous resizing.
Common Mistakes to Avoid
- Writing to the DOM inside the ResizeObserver callback without rAF. Even though the callback fires after layout, synchronous writes inside it schedule another layout for the current frame. Always defer writes to
requestAnimationFrame. - Forgetting the zero-dimension guard. Elements hidden with
display: noneor not yet painted returncontentRectof0×0. Without the guard, downstream calculations produceNaNor divide-by-zero errors that are difficult to trace. - Not calling
disconnect()on unmount. Forgetting to disconnect holds a strong reference to the observed element, preventing garbage collection and accumulating detached DOM nodes across route transitions — a common source of heap bloat in SPAs. See Preventing Memory Leaks in Long-Running Observers for the full teardown pattern. - Using
window.resizeas a fallback alongsideResizeObserver. Running both simultaneously doubles the callback load and reintroduces the forced-reflow problem. Check browser support once at startup and useResizeObserverexclusively when available.
Related
- DOM Query Minimization — caching node references, batching instantiation, eliminating
querySelectorbottlenecks - Callback Throttling & Debouncing — rate-limiting high-frequency observer callbacks
- Optimizing IntersectionObserver for 1000+ List Items — batching strategies for large element sets
- Syncing Observer Callbacks with requestAnimationFrame — rAF scheduling patterns across observer types
- Preventing Memory Leaks in Long-Running Observers — disconnect discipline and WeakMap teardown
↑ Back to DOM Query Minimization