Build one application-wide ResizeObserver that stores a handler per element in a WeakMap, observes each element with its own box option, isolates handler errors so one component cannot break the others, and returns an idempotent unsubscribe — components then subscribe elements instead of constructing observers.
Problem / Scenario Context
A design-system app has dozens of components that each create a ResizeObserver: text truncation, charts, tables, masonry cards, auto-sizing textareas. A typical dashboard page mounts 300 of them. Heap snapshots show 300 ResizeObserver instances and 300 callback closures, profiling shows callbacks delivered in 300 separate invocations per resize frame, and one component's exception — a chart failing on a zero-width container — surfaces as an uncaught error that occasionally breaks other components' updates in the same frame.
The Shared Observer Pooling topic makes the general case for sharing; keying an observer pool by threshold and rootMargin covers intersection pools. ResizeObserver sharing is simpler in one way — it has no constructor options — and needs care in others.
Mechanics Explanation
ResizeObserver has no constructor options: the only per-observation setting is box, passed to observe(target, { box }). So one instance can serve every element in the app, each with its own box option. That removes the need to key pools by options, which intersection pools require.
Delivery semantics also favour sharing. The browser gathers every changed observation across all observers in the same rendering-steps loop and calls each observer's callback with its entries. With one observer, all changed elements arrive in a single callback, in DOM depth order, which lets the service batch reads before writes across components.
Two concerns need explicit handling:
- Error isolation. An exception thrown by one handler inside a shared callback aborts the loop over entries unless caught, so later elements' handlers never run in that frame.
- Re-entrancy and loops. A handler that writes sizes may cause further entries in the same frame (deeper elements) or trigger the loop limit (same or shallower elements). With many components sharing one callback, one badly behaved component's loop is everyone's loop error; logging which handler caused it makes it diagnosable.
Comparison Table: Service Design Decisions
| Decision | Option | Recommendation |
|---|---|---|
| Handler storage | Map / WeakMap |
WeakMap + explicit unobserve |
| Multiple handlers per element | disallow / allow a Set |
allow — two components may observe the same element |
| Box option conflicts | first wins / last wins / per handler | observe with the most specific box; compute others from the entry |
| Errors | propagate / isolate | isolate and report with the handler's name |
| Delivery order | as delivered / sorted | as delivered (depth order); don't re-sort |
| Unsubscribe | unobserve immediately |
remove handler; unobserve when the element has none left |
Minimal Reproducible Example
// In every component:
class Truncate {
#ro = new ResizeObserver(([e]) => this.update(e));
constructor(private el: HTMLElement) { this.#ro.observe(el); }
update(_e: ResizeObserverEntry): void { /* … */ }
destroy(): void { this.#ro.disconnect(); }
}
Three hundred components, three hundred observers, three hundred callbacks per frame of a sidebar animation.
Production-Safe Solution
type Handler = (entry: ResizeObserverEntry) => void;
interface Subscription { handler: Handler; box: ResizeObserverBoxOptions; name: string }
const BOX_RANK: Record<ResizeObserverBoxOptions, number> = {
'content-box': 0, 'border-box': 1, 'device-pixel-content-box': 2,
};
class ResizeService {
#subs = new WeakMap<Element, Set<Subscription>>();
#boxes = new WeakMap<Element, ResizeObserverBoxOptions>();
#ro = new ResizeObserver((entries) => {
for (const entry of entries) {
const subs = this.#subs.get(entry.target);
if (!subs) continue;
for (const s of subs) {
try { s.handler(entry); }
catch (err) { reportError(new Error(`ResizeService handler "${s.name}" failed`, { cause: err })); }
}
}
});
observe(el: Element, handler: Handler, opts: { box?: ResizeObserverBoxOptions; name?: string } = {}): () => void {
const sub: Subscription = { handler, box: opts.box ?? 'content-box', name: opts.name ?? 'anonymous' };
let set = this.#subs.get(el);
if (!set) { set = new Set(); this.#subs.set(el, set); }
set.add(sub);
// Observe with the most demanding box any subscriber asked for; all sizes are on every entry.
const current = this.#boxes.get(el);
if (!current || BOX_RANK[sub.box] > BOX_RANK[current]) {
this.#boxes.set(el, sub.box);
try { this.#ro.observe(el, { box: sub.box }); }
catch { this.#ro.observe(el); this.#boxes.set(el, 'content-box'); } // unsupported box
}
let done = false;
return () => {
if (done) return;
done = true;
set!.delete(sub);
if (set!.size === 0) { this.#subs.delete(el); this.#boxes.delete(el); this.#ro.unobserve(el); }
};
}
}
export const resizeService = new ResizeService();
// Components subscribe instead of constructing:
const stop = resizeService.observe(tableEl, (e) => fitColumns(e), { name: 'DataTable.columns' });
declare const tableEl: HTMLElement;
declare function fitColumns(e: ResizeObserverEntry): void;
Every entry carries all box sizes regardless of which box was observed, so when two subscribers want different boxes, observing the "most demanding" one (device-pixel > border > content) delivers at least as often as each needs, and each handler reads the size it cares about. Re-observing an already-observed element with a new box replaces the previous box option, as the specification allows. Errors are reported with the handler's name, and the unsubscribe is idempotent, so components can call it from multiple teardown paths safely.
Is Sharing Always Worth It?
For a handful of components, no measurable difference exists. The benefits scale with count:
- Construction and memory — one instance instead of hundreds of instances and closures; see one observer vs many.
- Batched delivery — one callback per frame lets a service run all reads before all writes across components, which per-component observers cannot coordinate.
- Observability — one place to add timing, loop detection and error reporting.
Two reasons to keep a separate observer for a component: a third-party library that manages its own observer internally, which is fine to leave alone; and a component whose handler legitimately performs expensive synchronous work you want to attribute separately in traces — even then, the service's named handlers usually give enough attribution.
Verification Steps
- Heap snapshot: exactly one
ResizeObserverinstance from your code. - Throw from one handler in development and confirm the others still run and the error names the handler.
- Subscribe the same element from two components with different boxes and confirm both receive entries.
- Unmount components and confirm elements are unobserved when their last subscriber leaves.
- Record a trace during a resize animation and confirm one callback per frame.
Common Mistakes to Avoid
- A
Mapwithout unobserve. Removed elements stay observed and retained. - One handler per element. Two components observing the same element overwrite each other.
- Letting one handler's exception abort the loop. Isolate each call.
- Re-sorting entries. Delivery order already reflects DOM depth, which loop handling depends on.
FAQ
Can one ResizeObserver really serve the whole app?
Yes. ResizeObserver has no constructor options, and the box option is per observed element, so a single instance can observe any number of elements with different boxes.
What happens if I call observe again on an already-observed element?
The element's observation is updated with the new options, such as a different box. It does not create a duplicate observation.
Does sharing change when callbacks run?
No. All ResizeObserver callbacks run in the same rendering steps. Sharing changes how many invocations there are, not when.
Why WeakMap if I unobserve explicitly anyway?
As a safety net: if a component forgets to unsubscribe and its element is removed, the service's bookkeeping will not keep the element alive, though the observation itself still will until unobserved. Explicit unsubscription remains the rule.
How do I detect which handler caused a loop-limit error?
Count handler invocations per element within a frame using a frame counter; an element delivered more than twice in one frame points at a handler that writes to its own depth. Log its subscription name.
Should the same service handle IntersectionObserver too?
Keep them separate. Intersection observers need one instance per root, margin and threshold combination, so their pool is keyed by options, while the resize service needs only one instance.
Related
- Keying an Observer Pool by Threshold and rootMargin — the intersection counterpart
- Building a Lit Reactive Controller for ResizeObserver — a consumer of a shared instance
- Building a useResizeObserver Hook in React — wiring the service into React
↑ Back to Shared Observer Pooling