Architecture
This page explains how @crimson_dev/use-resize-observer achieves single-observer pooling, rAF batching, and startTransition integration. Understanding this architecture helps you make informed decisions about performance tuning.
High-Level Overview
flowchart TB
subgraph Components["React Components"]
A["Component A\nuseResizeObserver()"]
B["Component B\nuseResizeObserver()"]
C["Component C\nuseResizeObserver()"]
end
subgraph Pool["Shared Observer Pool"]
RO["Single ResizeObserver"]
MAP["WeakMap<Element, Callback | Set<Callback>>"]
end
subgraph Scheduler["rAF Scheduler"]
RAF["requestAnimationFrame"]
ST["startTransition"]
BATCH["Batched setState"]
end
A --> |observe| RO
B --> |observe| RO
C --> |observe| RO
RO --> |entries| RAF
RAF --> ST
ST --> BATCH
BATCH --> |single render| ComponentsThe Shared Observer Pool
Instead of creating one ResizeObserver per hook instance, all hook instances share a single observer through a module-level pool. This is the key architectural decision that enables scaling to hundreds of observed elements.
How it works
- When
useResizeObservermounts, it registers the target element with the pool. - The pool maintains a
WeakMap<Element, Callback | Set<Callback>>— storing a single callback directly (fast path) and only promoting to aSetwhen multiple callbacks observe the same element. - A single
ResizeObserverinstance observes all registered elements. - When the observer fires, entries are dispatched to the correct callbacks via the WeakMap lookup. Single-callback entries skip Set iteration entirely.
sequenceDiagram
participant Hook as useResizeObserver
participant Pool as ObserverPool
participant RO as ResizeObserver
participant DOM as Browser Layout
Hook->>Pool: observe(element, { box }, callback)
Pool->>Pool: weakMap.set(element, callback) [fast path]
Note right of Pool: Promotes to Set on 2nd callback
Pool->>RO: observe(element, { box })
DOM->>RO: layout change detected
RO->>Pool: callback(entries)
Pool->>Pool: for each entry, lookup callback or Set
Pool->>Hook: invoke registered callback(s)
Hook->>Pool: unobserve(element, callback)
Pool->>Pool: delete or demote Set to single callback
alt No more callbacks for element
Pool->>RO: unobserve(element)
endWeakMap cleanup
Using a WeakMap keyed by the DOM element ensures that when an element is garbage collected (after unmounting), its entry in the map is automatically cleaned up. No manual memory management required.
Why not one observer per box model?
The ResizeObserver API takes the box model per observe() call, so different elements with different box models share one observer without conflict — the pool simply passes each element's options through.
The one subtlety is the same element observed twice with different box models: a native observer keeps only the latest options per target, so the most recent observe() decides which box triggers notifications. Readings stay correct regardless, because every delivered entry carries all three size arrays. See Box Models.
rAF Batching
Raw ResizeObserver callbacks fire synchronously during the browser's layout step, potentially multiple times per frame. Calling setState directly from this callback would trigger synchronous re-renders.
Instead, we defer state updates to the next requestAnimationFrame:
flowchart LR
RO["ResizeObserver\ncallback"] --> BUF["Double Buffer\n(Map[active] XOR swap)"]
BUF --> RAF["requestAnimationFrame"]
RAF --> ST["startTransition(() => {\n flush swapped buffer\n})"]
ST --> R["Single React\nrender cycle"]The batching algorithm
- When the observer fires, entries are written to the active buffer (
Map<Element, FlushEntry>). - If no rAF is scheduled, one is requested.
- On the next animation frame, the active buffer is swapped via XOR (
active ^= 1) — zero allocation. - All pending entries are flushed inside a single
startTransitioncall. - React batches all the resulting
setStatecalls into one render.
The double-buffer swap means new resize events can accumulate in the fresh buffer while the previous buffer is being flushed. This eliminates per-flush new Map() allocation entirely.
This means that even if 100 elements resize simultaneously (e.g., during a window resize), only one React render cycle occurs.
The full pipeline, layer by layer, from the observer callback to a single painted frame:
startTransition Integration
The flush is wrapped in React.startTransition. This marks the resize updates as non-urgent, allowing React to:
- Interrupt the resize render if a higher-priority update (like user input) arrives
- Batch the resize updates with other pending transitions
- Avoid blocking the main thread with large resize cascades
// Internal simplified pseudocode
const flush = () => {
const entries = drainPendingQueue();
startTransition(() => {
for (const [element, entry] of entries) {
const callbacks = pool.get(element);
callbacks?.forEach(cb => cb(entry));
}
});
};When startTransition is not desired
If you need to react to measurements without waiting for a React render (e.g., canvas drawing), use the onResize callback instead of the reactive width/height return values. The callback still goes through the rAF batcher (at most one frame of latency), but it executes synchronously during the flush — you do not wait for the transition to commit a render.
Lifecycle Management
Mount
flowchart TD
MOUNT["useEffect runs"] --> REF{"ref.current\nexists?"}
REF -->|yes| REG["pool.observe(element, { box }, callback)"]
REF -->|no| WAIT["Wait for ref assignment"]
WAIT --> REF
REG --> OBS["ResizeObserver.observe(element, { box })"]Unmount
Cleanup relies on the useEffect cleanup function. The pool's unobserve method decrements the callback set and, when no callbacks remain for an element, calls ResizeObserver.unobserve:
// Simplified internal implementation (see src/hook.ts)
useEffect(() => {
const element = targetRef.current;
if (!element) return;
const pool = getSharedPool(root ?? element.ownerDocument, customCtor);
pool.observe(element, { box }, callback);
return () => {
pool.unobserve(element, callback);
};
}, [targetRef, box, root, ResizeObserverCtor]);Those four dependencies are the whole re-observation contract: swap the ref, change the box model, change the root, or inject a different constructor through context, and the element is unobserved and re-observed. Nothing else re-registers.
The cleanup function runs when the effect re-runs or the component unmounts. Additionally, FinalizationRegistry acts as a safety net for GC-backed cleanup if the effect cleanup is missed.
Memory Layout
For the standard (non-worker) mode, the per-element footprint is small and bounded:
| Allocation | Approx. size | Lifetime |
|---|---|---|
| WeakMap entry (single callback) | ~48 B | Element lifetime |
| WeakMap entry (promoted to Set) | ~96 B | Element lifetime |
| Double-buffer Map entry | ~48 B | Single frame |
NOTE
The byte figures are order-of-magnitude estimates for V8 — engine internals for WeakMap and Set are implementation details and will differ across runtimes and versions. The structural guarantees below are the part that holds everywhere.
What is guaranteed structurally: no per-element ResizeObserver instance, no per-element closure for the observer callback, no retained ResizeObserverEntry objects after the flush, and no new Map() allocation on flush (buffers are reused via XOR swap).
Worker Mode Architecture
Worker mode adds a SharedArrayBuffer layer for zero-copy data sharing. See the Worker Mode page for the full architecture, but in brief:
ResizeObserver is a DOM API and must run on the main thread. Worker mode uses a main-thread observer that writes measurements directly into a SharedArrayBuffer via writeSlot(). This SAB can then be read by compute workers (WebGL, WASM) without message passing.
flowchart LR
subgraph Main["Main Thread"]
RO["ResizeObserver"] --> WRITE["writeSlot()\nFloat16Array + Atomics.store(dirty=1)"]
POLL["rAF poll loop"] --> CHECK["Atomics.load(dirty)"]
CHECK -->|dirty=1| READ["readSlot()\nFloat16Array + Atomics.store(dirty=0)"]
CHECK -->|dirty=0| SKIP["Skip frame"]
READ --> ST["setState()"]
end
subgraph Workers["Compute Workers (optional)"]
CW["WebGL / WASM"] --> SAB_READ["Atomics.load / wait\nread SAB directly"]
end
WRITE -.-> SAB_READThe SharedArrayBuffer (3,072 bytes) is divided into two regions: bytes 0--1023 for Int32Array dirty flags and bytes 1024--3071 for Float16Array measurement data (8 bytes per slot, 4 x Float16). The main-thread observer writes measurements and sets dirty flags via Atomics.store(). The rAF poll loop reads and calls setState only when dirty -- skipping unchanged frames entirely. Compute workers can also read the SAB directly for real-time layout data.
Next Steps
- Performance -- Benchmark data proving the architecture's benefits
- Worker Mode -- Deep dive into SAB-based measurement sharing
- Bundle Size -- How tree-shaking keeps the main entry at 1.12 kB