Re-testing a nine-year-old performance claim, prompted by use-resize-observer#80. July 2026.
- The original claim is real and correctly cited — a genuine benchmark by the Blink engineer who implemented ResizeObserver. I twice concluded it was a misattribution and was twice wrong.
- It no longer reproduces at the same magnitude. Re-running that experiment on a 2026 engine, the gap narrows from 8.5× (+4.30 ms/frame) to 1.9× (+0.089 ms/frame) — the absolute penalty is ~48× smaller. The bottleneck it identified has largely been optimised away.
- In a realistic steady state, 500 idle ResizeObservers cost 0.02 ms/frame in total, and how they're grouped makes no measurable difference.
- I could find no published re-measurement in the nine years since, including from the component libraries that cite the original when explaining their architecture.
- For a React hook specifically, sharing an observer removes no React work, so the end-to-end win at 20 components is ~93 µs/frame — and mounting was slightly slower.
Conclusion: keeping one ResizeObserver per hook instance is the right call.
If you've ever read that you should share a single ResizeObserver, the trail leads to
WICG/resize-observer#59. That issue
has exactly one comment, from Eric Portis in 2018:
The recommended pattern is to use one
ResizeObserverto observe multiple elements. @atotic looked into the performance gains of using one-vs-many ROs – they are dramatic.
…linking to a blink-dev thread. That single comment is the root of essentially every "share your observer" recommendation on the web. The issue was closed 80 minutes later. There is no reply from the implementer.
My first assumption was that the claim had drifted from its source. It hasn't. The linked message is Aleks Totic, 4 Nov 2017, posted during the ResizeObserver Intent-to-Ship, and it contains a proper benchmark:
Experimental setup: Observe size of 126 divs during a 2 second animation of width/height. This would generate ~2x60x126=15120 notifications at 60fps.
1) All elements are observed by a single ResizeObserver … Total observer time: 69ms
2) Each element is observed by separate ResizeObserver … Performance was surprisingly bad, with notifications taking up 25% of total animation time. Total observer time: 585ms
3) All elements observed by a single ResizeObserver, + manual handler dispatch … Surprisingly, this experiment ran almost as fast as #1: Total observer time: 75ms … It looks like C++ to JS is the bottleneck.
69 ms vs 585 ms — an 8.5× gap. Two details tend to drop out when this is summarised:
- The famous "10×" is a different comparison. That's a fourth experiment (RO reimplemented as DOM events) vs the first. The one-vs-many gap is 8.5×.
- The mechanism is C++→JS callback crossings, not observer bookkeeping. Experiment #3 is the control that proves it: one observer plus manual JS fan-out to 126 per-element handlers cost 75 ms ≈ experiment #1's 69 ms. The fan-out is free. What costs is how many times per frame the engine crosses from C++ into JS.
Two separate research passes told me this link was mis-targeted at an earlier, benchmark-free message, and that the "126 divs" passage might not exist. Both were wrong — Google Groups silently truncates the 1.49 MB thread, so partial fetches genuinely appear to lack it.
Settled by byte offset in the raw HTML: message F5-VcUZtBAAJ begins at 1085115
("Aleks Totic … Nov 4, 2017, 1:31:49 AM"), the benchmark text sits at 1093886–1095306,
and there is no intervening data-doc-id. To reproduce:
curl -sL -A "Googlebot/2.1 (+http://www.google.com/bot.html)" \
"https://groups.google.com/a/chromium.org/g/blink-dev/c/z6ienONUb5A/m/F5-VcUZtBAAJ"(lDU3VppIAgAJ is the separate 5 Oct 2017 field study in the same thread — see §2.)
The citations are sound — they point at a real benchmark. What I could not find is a published re-measurement, including from libraries that adopted the shared-observer pattern. (I verified the first two rows directly; the rest is survey work and may be incomplete.)
| Project | Stated rationale | Published numbers? |
|---|---|---|
| Angular CDK | "Sharing a ResizeObserver instance is recommended for better performance (see …issues/59)" | Not found |
| @react-hook/resize-observer | "This approach is astoundingly more performant" | Not found |
| mui-x#14929 | cites the same link | Not found |
| radix-ui, chakra, floating-ui, vueuse | one observer per instance; not discussed | — |
| Headless UI | removed ResizeObserver entirely, for correctness | — |
Two details are worth noting:
- On Angular's PR #26028, a reviewer asked whether creating observers or monitoring them was the expensive part. The reply was "Clarified why in the comments" — i.e. adding the WICG link. The PR included a perf-test app that was removed before merge, and I found no numbers posted.
- Angular's shared implementation filters with
entries.some(e => e.target === target)per subscriber — O(subscribers × entries) per delivery. This is the trade-off noted in use-resize-observer#24 in 2020: a shared observer moves work from the engine into JS.
It's worth adding that not everyone cites it for speed. MUI's stated rationale is that batched delivery lets you drop debouncing, which fixed a scroll-jumping bug — a correctness argument. The polyfill author, que-etc, likewise calls the performance difference "somewhat marginal" and rests his case on correctness.
Relevant context sits in the same blink-dev thread as the benchmark: Totic's 5 Oct 2017 field study of real sites found they observed 1–4 elements (one outlier at 28), and attributed the slow frames he saw to "lots of DOM manipulation inside RO callback" rather than to observer count.
In w3c/IntersectionObserver#81, discussing the sibling API with the same architecture:
You can use a single observer for many elements, or multiple observers, both should perform similarly except for the extra wrapper and allocation cost … The design of the API is not what's going to make this slow. — Elliott Sprehn, Blink
I suspect that the performance overhead will be dominated by the number of elements you are observing, not by the number of observers you make.
TL;DR: I wouldn't worry too much about creating many observers to start with. — Ojan Vafai, Blink
These were about IntersectionObserver, but the two APIs share the same per-frame architecture, so the reasoning carries over.
Two further details:
- Blink ships IntersectionObserver perf tests including a 1000-instance
many-objects.html, but I found no equivalent ResizeObserver perf test (resize_observer_test.cccovers correctness only). - In Gecko and WebKit, observers are registered on
observe()rather than at construction (Bugzilla 1596992), so a constructed-but-idle observer costs nothing per frame there.
One caveat on transferring the advice between the two APIs: for IntersectionObserver with
an explicit root, Blink caches root geometry in a single slot keyed on observer identity,
so N observers do recompute root geometry N times per frame. The shared-observer argument is
materially stronger in that specific case. It does not apply to ResizeObserver.
Source: ZeeCoder/use-resize-observer benchmark harness.
Environment: Chromium 151, Firefox 153, WebKit 26.5 via Playwright, headless, on an
i7-1185G7. Medians over interleaved repetitions with bootstrap confidence intervals.
Four design decisions did most of the work. Each turned out to be necessary, and the first in particular is easy to miss — it would silently produce a "no difference" result.
1. Disable vsync, or you measure nothing. requestAnimationFrame is vsync-paced. The
effect being measured is 0.02–0.9 ms against a 16.6 ms budget, so without
--disable-frame-rate-limit --disable-gpu-vsync every configuration returns exactly the
refresh interval. There is no equivalent flag for headless WebKit via Playwright, which is
why WebKit could not be measured at all — every config pinned to 16.23 ms/frame with a
MAD of 0.008.
2. Include a zero-observer control. Without it, "1 and 500 measured the same" is ambiguous between there is no difference and this harness can't see one. The control establishes the sensitivity floor and makes a null result interpretable. It's also what exposed the WebKit clamp — it reported observers costing 0.0000 ms/frame, which is obviously false.
3. Sweep granularity at constant total observations. Total observations is always N;
only the number of ResizeObserver instances they're spread across varies (0, 1, 4, 16, 64,
N). A two-point A/B tells you there's a gap but not whether it tracks observer count.
4. Measure at the React level too. This is the number that actually matters for a hook library, and it's the one the raw-DOM benchmark overstates. See §4.3.
Four workload phases:
| phase | what changes each frame | what it isolates |
|---|---|---|
idle-dirty |
an unobserved element | cost of merely having N observers — the realistic case |
sparse |
one observed element | one notification, N observers still walked |
churn |
every observed element | worst case — this is Totic's workload |
Same shape as Totic's setup: 126 divs, all resizing every frame, 120 frames.
| 1 RO × 126 | 126 ROs × 1 | gap | |
|---|---|---|---|
| 2017 (Totic) | 69 ms → 0.575 ms/frame | 585 ms → 4.875 ms/frame | 8.5×, +4.30 ms/frame |
| 2026 (this harness, over control) | +0.0996 ms/frame | +0.1887 ms/frame | 1.9×, +0.089 ms/frame |
The absolute penalty shrank roughly 48×. Per-observer-per-frame: ~34 µs in 2017, ~0.71 µs now. Some of that is faster hardware — call it 2–3× — but the rest is V8/Blink making the C++→JS crossing that Totic identified as the bottleneck dramatically cheaper.
0.089 ms/frame is 0.5% of a 16.6 ms frame budget, in the pathological case where all 126 elements resize on every single frame.
| phase | 1 RO | 500 ROs | difference |
|---|---|---|---|
idle-dirty |
+0.025 ms/frame | +0.020 ms/frame | −0.005 ms/frame (none) |
sparse |
+0.033 ms/frame | +0.074 ms/frame | +0.042 ms/frame (0.4%) |
churn |
+0.420 ms/frame | +0.877 ms/frame | +0.457 ms/frame (12.5%) |
In the steady state — where a real app spends essentially all its time — 500 idle observers cost 0.02 ms/frame in total, and granularity is irrelevant (500 measured marginally faster than 1, i.e. noise).
The granularity sweep under churn shows the trend is flat until high observer counts:
1 → 3.656, 4 → 3.617, 16 → 3.736, 64 → 3.784, 500 → 4.113 ms/frame.
Firefox shows the same qualitative pattern (+6.9% under churn). WebKit: unmeasurable.
This is the decisive one. Each hook instance owns its own setState, so a shared observer
removes callback invocations but cannot remove any React work. Two hooks identical
except for observer ownership, every hook setState-ing every frame:
| components | own RO | shared RO | saving |
|---|---|---|---|
| 20 | 0.848 ms/frame | 0.754 ms/frame | 0.093 ms/frame — mount was 0.10 ms slower |
| 100 | 2.596 ms/frame | 2.446 ms/frame | 0.151 ms/frame |
| 500 | 9.630 ms/frame | 8.922 ms/frame | 0.709 ms/frame |
At 20 components — already well above the 1–4 elements Totic's field study found on real sites — the saving is 93 microseconds per frame, and mounting was slower.
A second pass measured the same question with different methodology — CPU time via Chrome
DevTools Protocol Performance.getMetrics (threadTicks) rather than wall-clock frame
timing — and landed in the same place:
| this harness | independent pass | |
|---|---|---|
| marginal cost per observer per frame, resizing | 0.71–0.91 µs | ~1 µs |
| marginal cost per idle observer per frame | ~40 ns | ~21 ns |
That pass also isolated the crossing cost directly via the ScriptDuration counter:
~260 ns per C++→JS crossing today, against the ~39 µs implied by Totic's 2017 figures.
Two independent methods agreeing on a ~50–150× improvement is the core result here.
~50 bytes per extra observer on the V8 heap (500 observers = 24 KB more than 1). Including the Blink C++ object and JS wrapper, roughly 250–500 B per instance — about 0.25 MB at 1000 hooks. Not a factor.
Testing the "conflict" scenario que-etc warned about (observed element A's callback resizes observed element B), 1 shared observer vs 3 separate:
| engine | delivery order | RO loop errors | callback invocations |
|---|---|---|---|
| Chromium | identical | 1 vs 1 | 3 vs 5 |
| Firefox | identical | 1 vs 1 | 3 vs 5 |
| WebKit | identical | 0 vs 0 | 3 vs 5 |
Only the invocation count differs — same entries, split across more calls. His warning was about his polyfill's shared rAF/MutationObserver loop, not native ResizeObserver.
Per-frame work is O(total observations) in all three engines, essentially independent of grouping — the spec's gather step is a flat double loop over observers × their observations.
An idle observer costs one set-iteration and one isEmpty() test, with zero heap
allocation and no callback invocation —
§3.4.5: "If
observer's [[activeTargets]] slot is empty, continue." Blink's
ResizeObserver::DeliverObservations() early-returns before allocating its entries vector.
The depth loop that drives re-delivery is bounded by the DOM depth of changed elements, not by observer count — which is why splitting across N observers produces the same number of passes, and the same loop-error behaviour.
The genuine per-observer costs are all constant-factor or off the frame path: an O(N_observers) pointer-vector copy on frames where something resized, N callback invocations instead of 1, and the retained memory above.
Chromium's own stated threshold, from commit f425d06: they'd rather have "slower pages
which contain many (>10^4) observers" than slow down LayoutBox::StyleDidChange.
For use-resize-observer specifically, a shared singleton would have to give up or
re-implement three things the current design gets for free:
- Per-window observers. The hook uses
element.ownerDocument.defaultView?.ResizeObserverto fix #100 / #109 / #113 (elements in another window or cross-document iframe). A singleton needs aMap<Window, ResizeObserver>. - Exception isolation. Blink invokes each callback via
InvokeAndReportException, so a throwingonResizeis contained to its own observer. In a shared observer, one throwing handler aborts the loop overentriesand starves every other subscriber. - Per-instance
box/roundoptions, currently handled by recreating the instance.
Three new sources of subtle bugs, in exchange for ~0.1 ms/frame during active resize at 100 components.
A benchmark, yes. A CI assertion, no — at realistic scale the effect sits well below run-to-run noise on shared runners, so it would flake constantly while asserting nothing. It belongs as a script you re-run when the question resurfaces.
The 2017 benchmark was real, correctly cited, and correct for its time. What changed isn't the argument — it's the engine. The C++→JS crossing cost that made one-observer-per-element expensive has been optimised to the point where the same experiment now shows a 48× smaller penalty, and in the state real applications spend most of their time, no measurable difference at all.
The general lesson is probably the more useful one: performance results have a shelf life, and engine-level numbers age faster than the advice built on them.