Skip to content

disconnectedCallback

This lesson explains disconnectedCallback, when the browser calls it, and how to clean up timers, listeners, observers, and network requests when a custom element leaves the DOM.

What Is disconnectedCallback?

disconnectedCallback() is a custom element lifecycle method that runs when the element is removed from the document. Use it to tear down anything created in connectedCallback() so your component does not leak memory or keep running in the background.

In single-page applications, components mount and unmount frequently. Reliable cleanup in disconnectedCallback() keeps long-running apps stable.

Concept Description
disconnectedCallback() Runs when the element leaves the DOM
Lifecycle Type Custom element lifecycle callback
Common Use Cleanup timers, listeners, observers, requests
Runs Multiple Times? Yes, each time the element is removed
Paired With connectedCallback() for setup
Main Benefit Prevents memory leaks and stale side effects

Basic disconnectedCallback Example

class LiveClock extends HTMLElement {
  connectedCallback() {
    this.render();
    this._timer = setInterval(() => this.render(), 1000);
  }

  disconnectedCallback() {
    clearInterval(this._timer);
    this._timer = null;
  }

  render() {
    this.textContent = new Date().toLocaleTimeString();
  }
}

customElements.define("live-clock", LiveClock);

The interval starts on connect and stops on disconnect. Without clearInterval(), the timer would keep firing after the element was removed.

When disconnectedCallback Runs

Scenario Callback Runs?
element.remove() Yes
Parent replaces children with innerHTML Yes
SPA route change unmounts the component Yes
Element moved within the same document Sometimes disconnect then reconnect
Attribute change only No
Element hidden with CSS No

Disconnect means the element is no longer in the document tree, not merely hidden or inactive visually.

Pairing with connectedCallback

class DataPanel extends HTMLElement {
  connectedCallback() {
    if (this._initialized) return;
    this._initialized = true;

    this._controller = new AbortController();
    window.addEventListener(
      "resize",
      () => this.updateLayout(),
      { signal: this._controller.signal }
    );

    this.fetchData();
  }

  disconnectedCallback() {
    this._controller?.abort();
    this._controller = null;
    this._initialized = false;
  }
}

Every resource created in connectedCallback() should have a matching cleanup step in disconnectedCallback(). Reset flags such as _initialized if the element may connect again later.

What to Clean Up

  • setInterval() and setTimeout()
  • requestAnimationFrame() loops
  • Window, document, and media event listeners
  • IntersectionObserver, MutationObserver, ResizeObserver
  • AbortController for fetch and signal-based listeners
  • WebSocket, EventSource, and subscription connections
  • Third-party library instances tied to the element

Disconnecting Observers

connectedCallback() {
  this._observer = new IntersectionObserver((entries) => {
    if (entries[0].isIntersecting) this.loadContent();
  });

  this._observer.observe(this);
}

disconnectedCallback() {
  this._observer?.disconnect();
  this._observer = null;
}

Observers keep references to elements and callbacks. Always call disconnect() when the component unmounts.

AbortController Cleanup Pattern

async fetchData() {
  this._abortController = new AbortController();

  try {
    const response = await fetch(this.dataset.url, {
      signal: this._abortController.signal
    });
    const data = await response.json();
    this.render(data);
  } catch (error) {
    if (error.name !== "AbortError") throw error;
  }
}

disconnectedCallback() {
  this._abortController?.abort();
  this._abortController = null;
}

Aborting in-flight fetch requests prevents state updates on components that are no longer in the DOM.

Removing Event Listeners

connectedCallback() {
  this._onKeyDown = (event) => {
    if (event.key === "Escape") this.close();
  };

  document.addEventListener("keydown", this._onKeyDown);
}

disconnectedCallback() {
  document.removeEventListener("keydown", this._onKeyDown);
  this._onKeyDown = null;
}

Store listener references on this so you can remove the exact same function in disconnect. Anonymous functions cannot be removed later.

Reconnect After Disconnect

Custom elements can leave and re-enter the DOM. That means connectedCallback() and disconnectedCallback() may run multiple times during the element's life.

disconnectedCallback() {
  this.cleanup();
  this._initialized = false;
}

connectedCallback() {
  if (this._initialized) return;
  this._initialized = true;
  this.setup();
}

Design setup and cleanup to be idempotent so reconnecting does not create duplicate listeners or timers.

disconnectedCallback in Lit

disconnectedCallback() {
  super.disconnectedCallback();
  this._chart?.destroy();
  this._chart = null;
}

Lit components should call super.disconnectedCallback() so the base class can complete its own teardown before your custom cleanup runs.

SPA Route Change Example

// Route unmount removes the component from the page
router.onLeave(() => {
  viewContainer.innerHTML = "";
  // disconnectedCallback runs on child custom elements
});

Framework route changes often destroy DOM subtrees. If your custom elements register global listeners or polling in connect, they must clean up on disconnect or the SPA will leak memory over time.

connectedCallback vs disconnectedCallback

Callback When Typical Actions
connectedCallback() Element enters the DOM Render, listen, fetch, start timers
disconnectedCallback() Element leaves the DOM Stop timers, abort fetch, remove listeners

Full Cleanup Example

class MetricsWidget extends HTMLElement {
  connectedCallback() {
    if (this._active) return;
    this._active = true;

    this._abort = new AbortController();
    this._pollId = setInterval(() => this.refresh(), 30000);

    window.addEventListener(
      "online",
      () => this.refresh(),
      { signal: this._abort.signal }
    );

    this.refresh();
  }

  disconnectedCallback() {
    clearInterval(this._pollId);
    this._abort?.abort();
    this._pollId = null;
    this._abort = null;
    this._active = false;
  }

  async refresh() { /* fetch and render */ }
}

When disconnectedCallback Matters Most

  • Your component starts timers or polling on connect.
  • You attach listeners to window or document.
  • You create observers or long-lived subscriptions.
  • You fetch data that may finish after unmount.
  • The component is used in SPA or micro frontend shells.
  • You integrate third-party widgets with their own teardown APIs.

disconnectedCallback Best Practices

  • Mirror every connect setup action with a disconnect cleanup.
  • Store listener and timer IDs on this for removal.
  • Abort in-flight async work when the element unmounts.
  • Null out references after cleanup to avoid stale state.
  • Reset initialization flags if reconnect is possible.
  • Call super.disconnectedCallback() in Lit subclasses.
  • Test mount/unmount cycles in development.

Common disconnectedCallback Mistakes

  • Starting timers in connect with no cleanup on disconnect.
  • Adding document listeners with anonymous functions.
  • Assuming disconnect runs only once per element.
  • Updating DOM after async work completes post-unmount.
  • Forgetting to disconnect observers and subscriptions.
  • Leaving _initialized true after disconnect.
  • Skipping super.disconnectedCallback() in Lit components.

Key Takeaways

  • disconnectedCallback() runs when the element leaves the DOM.
  • It is the correct place for cleanup and teardown logic.
  • Pair it with connectedCallback() for balanced lifecycle design.
  • Timers, listeners, observers, and fetch requests must be stopped or removed.
  • Elements may disconnect and reconnect multiple times.
  • Good cleanup prevents memory leaks in dynamic applications.

Pro Tip

During code review, scan connectedCallback() for every side effect and verify there is a matching line in disconnectedCallback(). That simple habit catches most lifecycle leaks early.