skip to content

A design system has thirty components, and each one constructs its own ResizeObserver when it mounts. As the lead, how would you decide between per-instance observers and a single shared observer with an element-to-handler registry, and what does each option cost?

level: principalimportance: nice to knowfreq 28%

answer

  1. the layout cost is identical either way
  2. policy, not performance
  3. a Map that never deletes
  4. one throwing handler, one batch

basics

~20 s

Per-instance observers keep ownership and failure local and are the right default. A shared observer with an element-to-handler registry cuts object count and gives one callback per frame plus one place to enforce policy, at the cost of a singleton whose registry can leak and whose callback is a shared failure point.

solid answer

~50 s

Start by naming what is actually saved. Both options make the browser measure the same boxes, so layout cost — the dominant cost — is identical. A shared observer saves observer objects and collapses many callback invocations per frame into one, which only matters at high instance counts, such as a virtualised grid with hundreds of cells. The real argument for centralising is policy, not performance: one place to round sizes, to guard against feedback loops, to isolate a throwing handler with `try`/`catch` per entry, and to expose a `subscribe(el, handler)` that returns its own unsubscribe so nobody hand-pairs `observe` and `unobserve`. The costs are a module-level singleton with a lifetime you must think about, and a registry `Map` keyed by elements that keeps detached nodes and their handlers alive if an entry is never deleted. My default is per-instance, and I centralise when instance counts are high or when there is a real cross-cutting rule to enforce.

code

javascript · 20 lines
javascript
const handlers = new Map();
let ro = null;

export function observeSize(el, handler) {
  ro ??= new ResizeObserver((entries) => {
    for (const entry of entries) {
      try {
        handlers.get(entry.target)?.(entry);
      } catch (err) {
        console.error(err);
      }
    }
  });
  handlers.set(el, handler);
  ro.observe(el);
  return () => {
    handlers.delete(el);
    ro.unobserve(el);
  };
}

go deeper

for a junior

Know that one ResizeObserver can watch many elements and that entry.target is how you tell them apart. That is the mechanism any shared design is built on.

for a middle

Explain what consolidation actually saves — observer objects and callback invocations, not layout work — and how a registry dispatches by entry.target while box stays per-observation.

for a senior

Argue the leak and failure-isolation consequences concretely: a registry entry never deleted holds a detached subtree, and an unguarded loop lets one throwing handler skip the rest of the batch.

for a principal

Own the call and its evidence: default to local ownership, centralise for cardinality or for a rule you need enforced once, define the invariants and the test that protects them, and question whether thirty size subscriptions should exist at all.

## What is actually being chosen The question is framed as a performance one, and it usually is not. Both designs observe the same elements and require the browser to compute the same layout and generate the same observations. What differs is the number of `ResizeObserver` objects, the number of times your callback code is entered per frame, and — most importantly — where the rules about size-driven behaviour live. ## The per-instance shape Each component constructs an observer in its setup path and disconnects it in teardown. Strengths: ownership is obvious and local, teardown is a single `disconnect()` on an observer with exactly one target, a throwing callback affects only that component, and the `box` option and rounding policy are chosen where the component's semantics are known. There is nothing global to initialise, which also keeps server-side rendering and module-import order uninteresting. Weaknesses: thirty components mean thirty observer objects and, in a frame where all thirty resize, thirty separate callback invocations. Each is another entry point where someone can write a feedback loop or forget the zero-size guard. There is no place to fix a systemic mistake once. ## The shared shape One observer, one `Map` from element to handler, and one `subscribe` function: ```javascript const handlers = new Map(); let ro = null; export function observeSize(el, handler) { ro ??= new ResizeObserver((entries) => { for (const entry of entries) { try { handlers.get(entry.target)?.(entry); } catch (err) { console.error(err); } } }); handlers.set(el, handler); ro.observe(el); return () => { handlers.delete(el); ro.unobserve(el); }; } ``` Note that `box` is an argument to `observe()`, not to the constructor, so a shared observer can still watch one element by border box and another by content box — sharing does not force one convention. Strengths: one callback invocation per frame carrying every changed entry; one place that rounds sizes and refuses to write anything that could resize an observed element; one place to catch a handler's exception so it cannot skip the remaining entries in the batch; and an API that hands back its own unsubscribe, which is the single most effective defence against leaked observations. Weaknesses: it is a singleton. Its lifetime spans route changes and hot reloads. The `Map` holds strong references to elements and handlers, so an entry never deleted keeps a detached subtree and its closure alive — a leak the per-instance design cannot have, because there the observer dies with the component. Dispatch cost is trivial, but the shared callback is a shared failure point, and a subtle bug in it is a bug in thirty components at once. ## Deciding Measure before centralising. If a profile does not show observer overhead, the performance argument is not available and you are choosing on architecture alone. I default to per-instance for ordinary components, because local ownership is worth more than a handful of objects, and I centralise when one of three things is true. First, **cardinality**: a virtualised list or grid that observes hundreds of cells, where per-instance construction and teardown churn is real. Second, **policy**: there is a rule every consumer must follow — snap to a breakpoint, never write a size from the callback, always tolerate zero sizes — and I want it enforced in one implementation rather than reviewed thirty times. Third, **API shape**: I want the only public way to react to size to be a subscription that cannot be left dangling. When I do centralise, I want the registry keyed by element with deletion on unsubscribe as an invariant, a test that asserts the map empties after teardown, and per-entry error isolation so one bad handler cannot silence the batch. ## The question behind the question Thirty components each observing their own size is itself a signal worth examining. Some of that work is genuinely imperative — telling a virtualised list how many rows fit, driving a measurement-dependent library. Some of it is layout logic that has drifted into JavaScript. As a lead the more valuable intervention is often to shrink the number of subscriptions rather than to make subscribing cheaper: fewer elements whose behaviour depends on a measured pixel value means fewer feedback loops, fewer teardown paths, and less to centralise in the first place.

  • Does a shared observer reduce the browser's layout work?
    No. The same elements are observed, so the same boxes are measured and the same observations are generated. What shrinks is the number of observer objects and the number of times your JavaScript callback is entered per frame. Layout dominates the cost, so pitching consolidation as a layout optimisation is a claim a profile will contradict.
  • Why not key the shared registry with a WeakMap so forgotten entries cannot leak?
    A `WeakMap` would stop the registry holding the element alive, but it does not solve the leak: the observer itself keeps the target under observation until `unobserve` is called, and the callback keeps running for it. The fix is the unsubscribe discipline — return the cleanup from `subscribe` — with the map choice as a secondary defence, not a substitute.
  • What breaks if the shared callback loops over entries without try/catch?
    An exception in one handler aborts the loop, so every entry after it in that batch is skipped for the frame. The failure is order-dependent and looks like an unrelated component intermittently not responding to resize. Isolating each dispatch keeps one bad subscriber from becoming a whole-system symptom.

saying these in an interview costs you the question

  • Assumes a shared observer reduces the browser's layout cost
  • Keeps a Map of element handlers and never deletes on teardown
  • Believes sharing forces every subscriber onto one box option
  • Lets one handler's exception abort the rest of the batch
  • Centralises on instinct without profiling observer overhead

context