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 for IntersectionObserver.
  • disconnect() removes all targets and, for IntersectionObserver, discards queued entries. The observer object stays usable: calling observe() again restarts it.

For MutationObserver:

  • There is no unobserve. Registrations are per target, but the only removal is disconnect(), 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.

unobserve() Versus disconnect()Two columns. unobserve removes one target, keeps the observer working for the others, is right for one-shot targets and removed list items, and is the only safe choice on a shared observer. disconnect removes every target and drops pending entries, is right when the observer's owner is torn down, and is the only removal MutationObserver offers.unobserve(target)Stops one target; the rest keep workingRight for one-shot loads and removed itemsThe only safe call on a shared observerNot available on MutationObserverdisconnect()Stops every target at onceDrops pending entries or recordsRight when the observer's owner is torn downObserver can be reused with observe()

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

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

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

Which Teardown Call?A decision chain. If the observer is shared with other code, call unobserve for your target only, and let the owner disconnect when empty. Otherwise, if it is a MutationObserver, call takeRecords and then disconnect. Otherwise, if only one of several targets is finished, call unobserve. Otherwise the observer's owner is being torn down: call disconnect.Is the observer shared with other code?unobserve(yourTarget); the owner disconnectswhen emptyyesnoIs it a MutationObserver?takeRecords(), process, then disconnect()yesnoIs only one of several targets finished?unobserve(target)yesnoThe owner is being torn down: disconnect().

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.

Re-Arming a Sentinel With unobserve and observeFour steps. The sentinel crosses into view and the callback starts loading the next page. The callback unobserves the sentinel so no further crossings are delivered during the load. When the load completes, the sentinel is observed again. The fresh observation delivers an initial entry, so if the sentinel is still visible the next page loads immediately.1Sentinel visibleCallback starts loading the next page.2unobserve(sentinel)No duplicate crossings while the fetch is in flight.3Load completesItems are appended; the sentinel moves down.4observe(sentinel) againA fresh initial entry: loads again at once if still visible.

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 unobserve at item level, never disconnect at owner level. The observer and its closure leak with the owner.
  • Looking for unobserve on 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.


↑ Back to Observer Lifecycle & Memory Management