An observer is reusable after disconnect(): calling observe() again restarts delivery with a fresh initial entry per target, so you can pause and resume or move a single instance between views without reallocating — as long as your callback's state is reset alongside it and the initial entries are expected.
Problem / Scenario Context
A tabbed dashboard shows one of five panels at a time. Each panel has cards that report visibility for analytics and a chart that follows its container's size. The first implementation created new observers every time a tab became active and threw them away when it was hidden — five tabs, repeated switching, hundreds of observers allocated per session, each with a closure capturing panel state. A refactor kept observers alive across tab switches but forgot to stop them, so hidden panels' ResizeObservers kept firing as the layout changed behind the scenes.
The middle ground — one observer per role, disconnected while its panel is hidden and re-observed when it returns — is cheaper than both and correct. The Observer Lifecycle & Memory Management topic covers the full lifecycle; this page covers the resume path.
Mechanics Explanation
disconnect() clears an observer's list of targets (and, for IntersectionObserver and MutationObserver, its pending records). It does not invalidate the object. Its callback and options remain, and observe(target) registers a target exactly as it did the first time:
IntersectionObserverresets the target's previous threshold index, so the next rendering update delivers an initial entry reporting the current state.ResizeObserverresets the last reported size to 0×0, so the next rendering update delivers the element's current size (if non-zero).MutationObserverregisters the node and options again; nothing is delivered until the next mutation.
Options are fixed at construction for the first two — you cannot change rootMargin or threshold on an existing IntersectionObserver — and are per-observe() call for MutationObserver and for ResizeObserver's box.
What an observer does not reset is anything in your callback's closure. Counters, "already seen" sets and timers from the previous activation are still there after resuming, which is the source of most reuse bugs.
Comparison Table: Strategies for Hidden Views
| Strategy | Allocations per switch | Callbacks while hidden | State bugs | Verdict |
|---|---|---|---|---|
| Create on show, discard on hide | new observer + closure | none | fresh state each time | wasteful |
| Create once, never stop | none | yes — wasted work | stale | wrong |
Create once, disconnect on hide, observe on show |
none | none | must reset state | recommended |
| Create once, keep observing, ignore callbacks while hidden | none | yes, ignored | flags to maintain | acceptable for cheap callbacks |
Minimal Reproducible Example
// Reused observer with stale callback state.
const seen = new Set<Element>();
const impressions = new IntersectionObserver((entries) => {
for (const e of entries) {
if (e.isIntersecting && !seen.has(e.target)) { seen.add(e.target); report(e.target); }
}
}, { threshold: 0.5 });
function showPanel(panel: HTMLElement): void {
panel.querySelectorAll('.card').forEach((c) => impressions.observe(c));
}
function hidePanel(): void {
impressions.disconnect();
}
declare function report(el: Element): void;
Analytics wants an impression per panel view. After returning to a panel, seen still contains its cards from the previous view, so no impressions are reported.
Production-Safe Solution
Wrap the observer in a small controller that owns both the observer and the per-activation state, and resets them together.
interface Activation { seen: Set<Element>; startedAt: number }
export class ReusableVisibility {
#io: IntersectionObserver;
#activation: Activation | null = null;
constructor(private onImpression: (el: Element, a: Activation) => void, init?: IntersectionObserverInit) {
this.#io = new IntersectionObserver((entries) => {
const a = this.#activation;
if (!a) return; // late entries after deactivate
for (const e of entries) {
if (!e.isIntersecting || a.seen.has(e.target)) continue;
a.seen.add(e.target);
this.onImpression(e.target, a);
}
}, init);
}
activate(targets: Iterable<Element>): void {
this.#activation = { seen: new Set(), startedAt: performance.now() }; // fresh state
for (const t of targets) this.#io.observe(t); // initial entries follow
}
deactivate(): void {
this.#io.disconnect(); // stop targets, drop pending entries
this.#activation = null; // and the state that belonged to them
}
}
const cards = new ReusableVisibility((el) => report(el), { threshold: 0.5 });
tabs.addEventListener('tabchange', (ev: Event) => {
cards.deactivate();
cards.activate((ev as CustomEvent<HTMLElement>).detail.querySelectorAll('.card'));
});
declare const tabs: HTMLElement;
Two guards matter. The activation object is replaced on every activate(), so no state leaks across views. And the callback checks for an active activation, because an entry computed just before deactivate() may still be delivered in a task that runs after it — with the activation cleared, it is ignored.
For ResizeObserver, the same shape applies, with one bonus: the initial entry on resume delivers the panel's current size, so a chart that was hidden while the window resized re-lays itself out correctly as soon as its tab is shown.
Why Not Just Create New Observers?
Allocation is cheap for one observer and not for many. A panel with fifty cards and one shared observer costs one allocation per switch if recreated — trivial. The problem appears when observers are created per component: fifty allocations and fifty closures per switch, each closure capturing component state, each a candidate for a leak if a teardown path is missed. The one observer vs many benchmark quantifies the construction and memory costs.
Reuse also makes behaviour more predictable: options are defined once, in one place, instead of being recomputed on every activation where a subtle difference (a rootMargin string built from a changing value) could silently create observers with different semantics.
The one case where recreating is required is changing options. IntersectionObserver's root, rootMargin and threshold are immutable; a panel that needs a different margin needs a different observer — which is where keying a pool by options comes in.
Verification Steps
- Switch tabs repeatedly and confirm, with a heap snapshot, that observer instance counts stay constant.
- Resize the window while a panel is hidden, then show it, and confirm its chart lays out at the new size from the initial entry.
- Return to a panel and confirm impressions are reported again per view.
- Switch tabs mid-scroll and confirm no impressions are attributed to the hidden panel.
- Log callbacks while hidden and confirm there are none.
Common Mistakes to Avoid
- Keeping callback state across activations. Reset it with the observation.
- Assuming no entry arrives after
disconnect(). A task already queued can still run; guard in the callback. - Trying to change
rootMarginon a live observer. Options are immutable; use a second observer. - Leaving hidden views observed. They burn callbacks on layout you are not showing.
FAQ
Can I call observe after disconnect?
Yes. disconnect only removes targets and pending records; the observer object remains fully usable, and observe registers targets as if for the first time.
Will I get an initial entry again after re-observing?
Yes, for IntersectionObserver and ResizeObserver. The observer forgets the target's previous state on disconnect, so the next rendering update reports its current state.
Can I change an IntersectionObserver's rootMargin before re-observing?
No. root, rootMargin and threshold are fixed at construction. Create another observer for different options.
Is it cheaper to disconnect or to ignore callbacks while hidden?
Disconnecting is cheaper and simpler to reason about, because the browser stops computing intersections or sizes for those targets. Ignoring callbacks still pays the computation and the callback invocation.
Does reusing an observer help with memory leaks?
It reduces the number of places a leak can happen, because there is one observer per role instead of one per activation. It does not remove the need to unobserve elements that are removed from the page.
Related
- Unobserve vs Disconnect: When to Use Each — choosing the teardown call
- Observing Elements That Are Not Yet in the DOM — the other side of activation
- Shared Observer Pooling — reuse across components
↑ Back to Observer Lifecycle & Memory Management