Skip to content

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 ​

mermaid
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| Components

The 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 ​

  1. When useResizeObserver mounts, it registers the target element with the pool.
  2. The pool maintains a WeakMap<Element, Callback | Set<Callback>> — storing a single callback directly (fast path) and only promoting to a Set when multiple callbacks observe the same element.
  3. A single ResizeObserver instance observes all registered elements.
  4. When the observer fires, entries are dispatched to the correct callbacks via the WeakMap lookup. Single-callback entries skip Set iteration entirely.
mermaid
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)
    end

Shared observer pool: three components multiplexed through one ObserverPool into a single ResizeObserver, WeakMap + FinalizationRegistry, RafScheduler, and one React render

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

mermaid
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 ​

  1. When the observer fires, entries are written to the active buffer (Map<Element, FlushEntry>).
  2. If no rAF is scheduled, one is requested.
  3. On the next animation frame, the active buffer is swapped via XOR (active ^= 1) — zero allocation.
  4. All pending entries are flushed inside a single startTransition call.
  5. React batches all the resulting setState calls 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:

Scheduling pipeline: ResizeObserver callback, entry deduplication with last-write-wins, rAF batcher, startTransition, React scheduler, single-frame paint

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
tsx
// 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 ​

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

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

AllocationApprox. sizeLifetime
WeakMap entry (single callback)~48 BElement lifetime
WeakMap entry (promoted to Set)~96 BElement lifetime
Double-buffer Map entry~48 BSingle 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.

mermaid
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_READ

The 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

Released under the MIT License.