Performance
Performance is the architecture. Every layer of @crimson_dev/use-resize-observer is designed to minimize work, from the single shared observer to the rAF-batched flush.
The Key Insight
100 elements resizing simultaneously produce exactly 1 React render cycle and 1 paint.
This is achieved through a 6-layer scheduling hierarchy:
graph TD
L0["Layer 0: ResizeObserver callback (~8ms budget)"]
L1["Layer 1: Entry Deduplication (Map last-write-wins)"]
L2["Layer 2: rAF Batcher (coalesces per frame)"]
L3["Layer 3: startTransition (non-urgent)"]
L4["Layer 4: React Scheduler (batched renders)"]
L5["Layer 5: Paint (single frame)"]
L0 --> L1 --> L2 --> L3 --> L4 --> L5Performance Targets
| Benchmark | Target | Notes |
|---|---|---|
Pool observe() throughput | > 1M ops/sec | WeakMap lookup, single-callback fast path |
Pool unobserve() throughput | > 1M ops/sec | WeakMap lookup, demote Set → callback |
| rAF flush latency (100 elements) | < 0.1ms | Double-buffer XOR swap + Map iteration |
| rAF flush latency (1,000 elements) | < 1ms | Still one rAF, one transition |
| Hook render overhead | < 0.01ms above useState | Minimal wrapper |
| Worker measurement p50 | < 33ms (2 rAF) | SharedArrayBuffer read |
| Worker measurement p99 | < 66ms (4 rAF) | Worst-case contention |
| Heap growth (10k cycles) | < 1 MB | WeakMap GC cleanup |
How the Scheduling Layers Work
Layer 0: ResizeObserver Callback
The browser fires the ResizeObserver callback during the layout step, between style recalculation and paint. This callback has a tight time budget -- any work done here delays the frame.
Our callback does the absolute minimum: write entries to the active buffer and schedule a rAF if one is not already pending.
// Simplified internal callback (single-callback fast path)
const callback: ResizeObserverCallback = (entries) => {
for (const entry of entries) {
const slot = registry.get(entry.target);
if (typeof slot === 'function') {
// Fast path: single callback, no Set overhead
scheduler.schedule(entry.target, entry, slot);
} else if (slot && slot.size > 0) {
scheduler.schedule(entry.target, entry, slot);
}
}
};Layer 1: Deduplication
If an element resizes multiple times before the rAF fires (which can happen during complex layout recalculations), only the last entry is kept. The double-buffered Map keyed by element ensures last-write-wins semantics with zero-allocation buffer swap (active ^= 1).
Layer 2: rAF Batcher
The requestAnimationFrame callback fires once per frame, regardless of how many resize events occurred. This collapses all resize activity into a single processing point. The buffer swap happens atomically via XOR — new events write to the fresh buffer while the previous buffer is being flushed.
Layer 3: startTransition
Inside the rAF callback, all state updates are wrapped in React.startTransition. This tells React that resize measurements are non-urgent -- user interactions like typing and clicking take priority.
sequenceDiagram
participant Browser
participant rAF as rAF Callback
participant React
Browser->>rAF: Frame start
rAF->>React: startTransition(() => flush())
React->>React: Batch all setState calls
React->>Browser: Single commit + paintLayer 4: React Scheduler
React's internal scheduler receives all the setState calls as a single batch within the transition. It produces one reconciliation pass and one commit.
Layer 5: Paint
The browser paints exactly once for all the resize updates in that frame.
Comparison with Naive Approach
Consider 100 elements resizing during a window resize event:
| Approach | Observer Instances | setState Calls | Renders | Paints |
|---|---|---|---|---|
| Naive (1 observer/element) | 100 | 100 (synchronous) | 100 | 1-100 |
| Upstream v10 | 100 | 100 (batched by React 18+) | 1-2 | 1 |
| This library | 1 | 100 (in startTransition) | 1 | 1 |
The key difference from the upstream is that even though React 18+ batches setState calls, each observer callback still runs independently. With 100 observers, you have 100 callback invocations, 100 closure allocations, and 100 observer instances created on mount. This library reduces all of that to 1.
GC-Backed Cleanup
The observer pool uses WeakMap<Element, Callback | Set<Callback>> keyed by DOM elements. When a component unmounts and its DOM element is garbage collected, the WeakMap entry is automatically cleaned up. Additionally, a FinalizationRegistry corrects the pool's element counter:
readonly #finalizer = new FinalizationRegistry<void>(() => {
// Element was GC'd — the browser's ResizeObserver internally stops
// observing garbage-collected targets. We only need to correct the
// size counter. WeakMap entries auto-clean on GC.
this.#size--;
});The browser's ResizeObserver stops observing garbage-collected targets on its own, and the WeakMap entry disappears with the element — so the registry's only job is keeping observedCount accurate. This holds even if a component's cleanup effect fails to run (edge case in strict mode double-invocation or error boundaries).
FinalizationRegistry timing
FinalizationRegistry callbacks are not guaranteed to run promptly. The primary cleanup mechanism is still the useEffect cleanup function (or using disposal). The registry is a safety net, not the primary path.
Worker Mode Performance
Worker mode adds latency (measurements travel through SharedArrayBuffer) but removes all resize-related work from the main thread:
| Metric | Main Thread Mode | Worker Mode |
|---|---|---|
| Observer callback work | Extract + schedule + setState | SAB write only (Float16Array + Atomics) |
| Measurement latency | 1 frame (rAF flush) | ~16-33ms (1-2 frames, rAF poll) |
| Zero-copy sharing with compute workers | — | Yes (workers read the SAB directly) |
| Best for | Most apps | Animation-heavy, canvas/WASM pipelines |
Note that the ResizeObserver itself always runs on the main thread in both modes — it is a DOM API. Worker mode's benefit is that measurements land in a SharedArrayBuffer that compute workers can read without message passing, and React state updates are skipped entirely on frames where nothing changed.
flowchart LR
subgraph MT["Main Thread Mode"]
A["Resize"] --> B["Callback"] --> C["rAF"] --> D["Render"]
end
subgraph WM["Worker Mode"]
E["Resize"] --> F["Observer writes SAB\n(Float16 + Atomics)"]
F --> G["rAF poll reads SAB\n(only when dirty)"]
G --> H["Render"]
F -.-> W["Compute workers\nread SAB directly"]
endV8 Optimization Status
All hot paths have been profiled with --trace-deopt --trace-opt on Node 25 (V8 13):
| Function | JIT Tier | Deopts | Notes |
|---|---|---|---|
ObserverPool.observe() | Turbofan | 0 | Monomorphic, fully optimized |
ObserverPool.unobserve() | Turbofan | 0 | Monomorphic, fully optimized |
RafScheduler.schedule() | Turbofan | GC tenuring only | Recompiled after allocation site moves to old gen — benign |
writeSlot() (Float16Array) | Turbofan | 0 | Clean optimized path |
readSlot() (Float16Array) | Turbofan | 0 | Clean optimized path |
No megamorphic inline caches (ICs) were observed. All object shapes are stable.
Running Benchmarks
# Run all benchmarks (outputs JSON to bench/results/)
npm run benchBenchmark results are stored in bench/results/ as JSON and uploaded as artifacts in CI. The .github/workflows/bench.yml workflow automatically posts benchmark results as PR comments.
Profiling Tips
Chrome DevTools
- Open the Performance tab
- Enable "Layout Shift Regions" and "Frame Rendering Stats"
- Record a window resize
- Look for the single green "Render" bar per frame -- that confirms batching is working
React DevTools Profiler
- Open the React DevTools Profiler
- Record a window resize
- Each frame should show exactly one render commit from the resize transition
Identifying unbatched renders
If you see multiple render commits per frame in the React Profiler during a resize, it likely means something outside this library is triggering synchronous state updates. Check for flushSync calls or other resize listeners.
Next Steps
- Architecture -- Deep dive into the pool and scheduler internals
- Worker Mode -- Detailed guide on SAB-based measurement sharing
- Bundle Size -- How the small footprint contributes to load performance