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.

Per-Component Observers Versus a Shared ServiceTwo columns. Per-component observers mean three hundred instances and closures, three hundred callback invocations per resize frame, and one component's exception surfacing as an uncaught error. A shared service means one instance and a WeakMap of handlers, one callback per frame with all changed elements, per-handler error isolation, and a single place for instrumentation.One observer per component300 instances, 300 closures300 callback invocations per frameErrors escape per componentInstrumentation scatteredShared service1 instance, WeakMap of handlers1 callback per frame, all changestry/catch per handlerOne place to measure and debug

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

TypeScript
// 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

TypeScript
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.

Subscribe, Deliver, UnsubscribeFour boxes. A component subscribes an element with a handler, a box option and a name. The service stores the handler in a WeakMap and observes the element with the most demanding box requested. Each frame, one callback delivers all changed elements and calls every handler inside a try and catch. When the last handler for an element unsubscribes, the element is unobserved.subscribe(el, fn)box option, nameWeakMap + observemost demanding boxwinsOne callbacktry/catch per handlerLast unsubscribeunobserve(el)

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.

Resize Frame Cost on a 300-Component DashboardA bar chart of script time per frame during an animated sidebar collapse on a dashboard with three hundred observed components. Per-component observers cost about four point two milliseconds per frame, largely callback overhead. A shared service with the same handlers cost about two point six milliseconds. The shared service with reads batched before writes across handlers cost about one point four.300 components, sidebar collapse animation, per frameper-component observers~4.2 ms/frameshared service~2.6 ms/frameshared + batched read/write~1.4 ms/frame

Verification Steps

  • Heap snapshot: exactly one ResizeObserver instance 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 Map without 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.


↑ Back to Shared Observer Pooling