Use unobserve(target) when one element is done — a one-shot lazy load, a list item that is removed — and the observer should keep watching the rest; use disconnect() when the observer itself is done, typically at component teardown, remembering that it also drops pending records and that MutationObserver has only disconnect().
Problem / Scenario Context
Two bugs from the same codebase. In the first, a lazy-image component calls io.disconnect() after loading its image — but the observer is a shared instance used by every image on the page, so the first image to load stops all the others from ever loading. In the second, an infinite list's row component calls io.unobserve(row) on unmount, which is correct, but the list component never disconnects the observer when the whole list unmounts, and navigating between feeds leaks one observer and its callback closure per visit.
The choice between the two calls follows directly from who owns what. The Observer Lifecycle & Memory Management topic describes the lifecycle; this page is the decision guide.
Mechanics Explanation
For IntersectionObserver and ResizeObserver:
unobserve(target)removes one target from the observer's list. The observer keeps working for its other targets. Entries already queued for that target may still be delivered in the next callback forIntersectionObserver.disconnect()removes all targets and, forIntersectionObserver, discards queued entries. The observer object stays usable: callingobserve()again restarts it.
For MutationObserver:
- There is no
unobserve. Registrations are per target, but the only removal isdisconnect(), which removes all of them and discards queued records. To stop watching one of several targets, disconnect and re-observe the rest.
Memory follows the references. An observer holds its targets strongly while they are observed, and holds its callback — and everything the callback closes over — for as long as the observer is alive. An observer with no targets and no references from your code can be collected; one that still has targets (even detached ones) keeps them alive.
Comparison Table: Which Call for Which Situation
| Situation | Observer ownership | Call | Why |
|---|---|---|---|
| Image loaded, lazy loader continues | shared | unobserve(img) |
others still need it |
| List row unmounted | shared per list | unobserve(row) |
list still observes siblings |
| Whole list/component unmounted | owned by component | disconnect() |
everything goes |
| Route change, pool has no targets left | pool | disconnect() when refcount hits 0 |
free the callback |
| Stop watching one of several MutationObserver targets | owned | disconnect() then re-observe() the rest |
no unobserve |
| MutationObserver teardown with pending changes | owned | takeRecords() then disconnect() |
do not drop records |
| Pause briefly, resume later | owned | disconnect() then observe() |
observer is reusable |
Minimal Reproducible Example
// Shared observer: disconnecting from inside one target's handler breaks everyone.
const shared = new IntersectionObserver((entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
load(e.target as HTMLImageElement);
shared.disconnect(); // meant "stop watching this image"
}
});
document.querySelectorAll('img[data-src]').forEach((img) => shared.observe(img));
declare function load(img: HTMLImageElement): void;
Only the first image to become visible loads; every other image remains a placeholder forever.
Production-Safe Solution
Make ownership explicit in the API, so each caller can only do what it owns.
export interface Registration { release(): void }
/** A shared observer that hands out per-target registrations. Callers cannot disconnect it. */
export class SharedIntersection {
#io: IntersectionObserver | null = null;
#handlers = new Map<Element, (e: IntersectionObserverEntry) => void>();
constructor(private readonly init: IntersectionObserverInit = {}) {}
register(el: Element, onEntry: (e: IntersectionObserverEntry) => void): Registration {
this.#io ??= new IntersectionObserver((entries) => {
for (const e of entries) this.#handlers.get(e.target)?.(e);
}, this.init);
this.#handlers.set(el, onEntry);
this.#io.observe(el);
let released = false;
return {
release: () => {
if (released) return;
released = true;
this.#handlers.delete(el);
this.#io?.unobserve(el); // per-target: unobserve
if (this.#handlers.size === 0) { // owner-level: disconnect when empty
this.#io?.disconnect();
this.#io = null;
}
},
};
}
}
// One-shot lazy load: release this registration only.
const lazy = new SharedIntersection({ rootMargin: '400px 0px' });
document.querySelectorAll<HTMLImageElement>('img[data-src]').forEach((img) => {
const reg = lazy.register(img, (e) => {
if (!e.isIntersecting) return;
img.src = img.dataset.src!;
reg.release();
});
});
Callers get a release() that can only unobserve their own element; the shared instance disconnects itself when its last registration is released. For a component-owned observer, the owner simply calls disconnect() in its teardown — no registration layer needed.
Unobserving From Inside the Callback
unobserve() inside an IntersectionObserver callback is the standard one-shot pattern and is safe, but two subtleties are worth knowing.
The current batch still contains the target's other entries. If a target crossed twice before the callback ran, both entries are in the array you are iterating. Unobserving on the first does not remove the second. Guard with a Set of handled targets when the effect must happen exactly once.
Unobserve then observe re-delivers. Calling unobserve(el) and later observe(el) again resets the observer's memory of that target, so the next rendering update delivers an initial entry for it, just as a fresh observation would. That is useful for "re-arm" patterns — for example, re-enabling a sentinel after a page of results has loaded — and surprising if unintended.
Verification Steps
- Load a page with many lazy images and confirm every one loads, not just the first.
- Remove list rows and confirm, via a counter or heap snapshot, that they are unobserved.
- Unmount the whole component repeatedly and confirm observer instances do not accumulate.
- For MutationObservers, mutate and tear down in the same task and confirm the last change is processed.
- Re-arm a sentinel and confirm the initial entry after
observe()triggers the next load when appropriate.
Common Mistakes to Avoid
disconnect()on a shared observer for one target. It stops every other target.- Only
unobserveat item level, neverdisconnectat owner level. The observer and its closure leak with the owner. - Looking for
unobserveon MutationObserver. It does not exist; disconnect and re-observe. - Forgetting that re-observing delivers an initial entry. Handle it, or it looks like a spurious crossing.
FAQ
Is it safe to call unobserve inside the IntersectionObserver callback?
Yes. It is the standard pattern for one-shot behaviour. Other entries for the same target already in the current batch are still delivered, so guard if the effect must run once.
Can I reuse an observer after disconnect?
Yes. disconnect removes all targets but leaves the observer usable. Calling observe again starts delivering entries for the new targets, beginning with an initial entry for each.
Does unobserve on an element that was never observed throw?
No. It is a no-op, which makes idempotent release functions easy to write.
Do I need to unobserve elements before removing them from the DOM?
You should, or use a pattern that does it for you. An observer keeps observed elements alive even after removal, so removed but still-observed elements are a common source of detached-node leaks.
Does disconnect free the observer's callback?
Not by itself. The callback lives as long as the observer object does. After disconnect, the observer has no targets keeping it alive, so once your own references to it are dropped it can be collected along with its callback.
Why does MutationObserver not have unobserve?
The API was designed around a single registration per observer in most uses, and nobody standardised per-target removal. When you need it, disconnect and observe the remaining targets again, calling takeRecords first if pending records matter.
Related
- Reusing One Observer After Disconnect — the reuse pattern in depth
- Using takeRecords Before Disconnect — not dropping pending records
- Disconnecting Pooled Observers on Route Change — owner-level teardown for pools
↑ Back to Observer Lifecycle & Memory Management