Ad – 728Γ—90
πŸ¦‰ Lifecycle

OWL JS onWillUnmount & Cleanup – Memory Leak Prevention

When OWL removes a component from the DOM, it calls onWillUnmount β€” the last chance to clean up everything the component created during its lifetime. Without proper cleanup, resources like timers, event listeners, observers, and subscriptions keep running indefinitely after the component is gone, causing memory leaks and ghost callbacks that produce subtle bugs. This lesson covers every cleanup pattern you need, plus the onError hook for building error boundaries that catch child component failures.

⏱️ 18 min read 🎯 Intermediate πŸ“… Updated 2026 πŸ‘οΈ Lesson 5 of 5

onWillUnmount Basics

onWillUnmount fires synchronously before the component is removed from the DOM. The element this.el is still accessible here β€” it is removed from the document after the hook completes.

JavaScript – basic onWillUnmount
import { Component, xml, onMounted, onWillUnmount } from "@odoo/owl";

class MyComponent extends Component {
  setup() {
    onMounted(() => {
      // Create something that needs cleanup
      this._timerId = setInterval(() => {
        console.log("tick");
      }, 1000);
    });

    onWillUnmount(() => {
      // Clean it up before the component is destroyed
      clearInterval(this._timerId);
    });
  }
}
⚠️
Every setup has a corresponding teardown

The golden rule: if you create it in onMounted, destroy it in onWillUnmount. This applies to: timers (setInterval, setTimeout), global event listeners (window.addEventListener), DOM observers (ResizeObserver, IntersectionObserver, MutationObserver), WebSocket connections, third-party library instances, and pub/sub subscriptions.

Complete Cleanup Patterns

Timers: setInterval and setTimeout

JavaScript – timer cleanup
class AutoRefresh extends Component {
  state = useState({ data: null, lastUpdated: null });

  setup() {
    onMounted(() => {
      // Start polling every 30 seconds
      this._pollInterval = setInterval(() => {
        this.refresh();
      }, 30_000);

      // Also set a one-shot timeout
      this._introTimeout = setTimeout(() => {
        this.state.showIntro = false;
      }, 5_000);
    });

    onWillUnmount(() => {
      clearInterval(this._pollInterval);  // stop polling
      clearTimeout(this._introTimeout);   // cancel pending timeout
    });
  }

  async refresh() {
    this.state.data        = await fetchData();
    this.state.lastUpdated = new Date();
  }
}

Global Event Listeners

JavaScript – event listener cleanup
class ModalWithEscape extends Component {
  setup() {
    // Store the bound reference β€” needed for removeEventListener
    this._onKeyDown = (ev) => {
      if (ev.key === "Escape") {
        this.props.onClose();
      }
    };

    onMounted(() => {
      // Listen globally for Escape key
      window.addEventListener("keydown", this._onKeyDown);
      // Prevent body scroll while modal is open
      document.body.style.overflow = "hidden";
    });

    onWillUnmount(() => {
      // Must pass the exact same function reference to remove it
      window.removeEventListener("keydown", this._onKeyDown);
      // Restore body scroll
      document.body.style.overflow = "";
    });
  }
}
πŸ’‘
Store the bound reference, not the original method

removeEventListener requires the exact same function reference that was passed to addEventListener. If you bind in onMounted (this.method.bind(this)), that creates a new function β€” you cannot remove it unless you stored the result. The cleanest pattern: create the bound reference in setup() as a property, then use it in both onMounted and onWillUnmount.

Ad – 336Γ—280

DOM Observers

JavaScript – ResizeObserver and IntersectionObserver cleanup
class LazyImage extends Component {
  static template = xml`
    <div t-ref="container">
      <img t-if="state.isVisible" t-att-src="props.src" t-att-alt="props.alt"/>
      <div t-else="" class="placeholder">Loading…</div>
    </div>
  `;

  containerRef = useRef("container");
  state = useState({ isVisible: false });

  setup() {
    onMounted(() => {
      // IntersectionObserver: load image only when it scrolls into view
      this._observer = new IntersectionObserver(
        ([entry]) => {
          if (entry.isIntersecting) {
            this.state.isVisible = true;
            this._observer.disconnect();  // stop watching after first intersection
          }
        },
        { threshold: 0.1 }
      );
      this._observer.observe(this.containerRef.el);
    });

    onWillUnmount(() => {
      // Stop observing β€” prevents callback firing after component is gone
      this._observer?.disconnect();
    });
  }
}

class ResizableWidget extends Component {
  state = useState({ width: 0 });

  setup() {
    onMounted(() => {
      this._resizeObserver = new ResizeObserver(([entry]) => {
        this.state.width = entry.contentRect.width;
      });
      this._resizeObserver.observe(this.el);
    });

    onWillUnmount(() => {
      this._resizeObserver?.disconnect();
    });
  }
}

Pub/Sub Subscriptions

JavaScript – subscription cleanup pattern
/** @odoo-module **/
import { Component, xml, useState, onWillStart, onWillUnmount } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";

class NotificationBell extends Component {
  state = useState({ count: 0 });

  setup() {
    this.bus = useService("bus_service");

    onWillStart(() => {
      // Subscribe to the Odoo bus
      this.bus.subscribe("notification_count", this._onNotification.bind(this));
    });

    onWillUnmount(() => {
      // Unsubscribe to prevent ghost callbacks
      this.bus.unsubscribe("notification_count", this._onNotification.bind(this));
      // Better: store the bound ref
    });
  }

  _onNotification({ count }) {
    this.state.count = count;
  }
}

The Stale Component Problem

A subtle but important bug: an async operation started while the component was alive completes after the component is unmounted. Setting state on an unmounted component causes errors and wasted work.

JavaScript – guard against stale component updates
class SearchPanel extends Component {
  state = useState({ results: [], isLoading: false });
  _isMounted = false;

  setup() {
    onMounted(() => {
      this._isMounted = true;
    });

    onWillUnmount(() => {
      this._isMounted = false;
      // Also cancel pending requests if using AbortController
      this._abortController?.abort();
    });
  }

  async search(query) {
    this._abortController = new AbortController();
    this.state.isLoading  = true;

    try {
      const results = await fetchSearch(query, {
        signal: this._abortController.signal,  // cancel on unmount
      });

      if (!this._isMounted) return;  // component gone β€” do nothing
      this.state.results   = results;
      this.state.isLoading = false;
    } catch (err) {
      if (err.name === "AbortError") return;  // expected β€” not an error
      if (!this._isMounted) return;
      this.state.isLoading = false;
    }
  }
}

onError – Error Boundaries

onError makes a component an error boundary β€” it catches errors thrown by any descendant component and lets the parent decide how to recover.

JavaScript – onError error boundary
import { Component, xml, useState, onError } from "@odoo/owl";

class ErrorBoundary extends Component {
  static template = xml`
    <div>
      <t t-if="state.hasError">
        <div class="error-fallback">
          <h3>Something went wrong</h3>
          <p t-esc="state.errorMessage"/>
          <button t-on-click="retry">Try Again</button>
        </div>
      </t>
      <t t-else="">
        <t t-slot="default"/>  <!-- normal children render here -->
      </t>
    </div>
  `;

  state = useState({ hasError: false, errorMessage: "" });

  setup() {
    onError((error) => {
      console.error("Caught by ErrorBoundary:", error);
      this.state.hasError    = true;
      this.state.errorMessage = error.message || "An unexpected error occurred.";
    });
  }

  retry() {
    this.state.hasError    = false;
    this.state.errorMessage = "";
    // Re-mounting the children requires remounting the whole boundary β€”
    // change a key prop or use a state-driven re-render
  }
}

// Parent β€” wraps risky components
class App extends Component {
  static components = { ErrorBoundary, RiskyWidget };

  static template = xml`
    <ErrorBoundary>
      <RiskyWidget/>
    </ErrorBoundary>
  `;
}

πŸ“‹ Summary

  • onWillUnmount fires synchronously before the component is removed. Use it to destroy everything created in onMounted.
  • Must clean up: setInterval/setTimeout, window.addEventListener, ResizeObserver/IntersectionObserver/MutationObserver, WebSocket connections, third-party library instances, pub/sub subscriptions.
  • Store bound function references as instance properties so removeEventListener can find the exact same reference.
  • For async operations that outlive the component, use an AbortController to cancel them and an _isMounted flag to guard state updates.
  • onError turns a component into an error boundary β€” it catches errors from all descendant components and can show a fallback UI instead of crashing the whole app.

πŸ‹οΈ Exercise

Build a WebSocketMonitor component to practice all unmount cleanup patterns:

  1. In onMounted, open a WebSocket connection (use new WebSocket("wss://echo.websocket.org") or simulate with a mock). Store the connection on this._ws.
  2. On message, push the message text into state.messages. Guard against updates after unmount.
  3. In onMounted also start a setInterval that sends a "ping" every 5 seconds.
  4. In onWillUnmount: close the WebSocket (this._ws?.close()) and clear the interval.
  5. Wrap the component in a parent with a "Connect / Disconnect" toggle that mounts/unmounts WebSocketMonitor via t-if. Open browser DevTools β†’ Network to confirm the WebSocket closes cleanly on unmount.

Frequently Asked Questions

Does OWL automatically clean up event listeners added with t-on-*? +

Yes β€” event listeners added declaratively with t-on-* in the template are managed by OWL. They are added when the element is rendered and removed when the component unmounts or the element is removed from the template (e.g., via t-if). You only need to manually clean up listeners added with addEventListener in your code β€” typically in onMounted for window or document listeners.

What happens if I forget to clean up? +

Several problems: (1) Memory leaks β€” the component's closure keeps all referenced objects alive in memory even after unmount. In long-running Odoo sessions where views are opened and closed repeatedly, this adds up. (2) Ghost callbacks β€” a setInterval or WebSocket message handler fires after unmount and tries to mutate useState on a destroyed component, causing warnings or errors. (3) Duplicate listeners β€” if the component re-mounts (toggle with t-if), onMounted adds another listener without removing the previous one.

Can onWillUnmount be async? +

OWL does not await onWillUnmount. If you pass an async function, OWL calls it but does not wait for the Promise to resolve before removing the component from the DOM. Cleanup must complete synchronously. For async cleanup (like flushing a pending analytics batch), start the async operation but do not block unmounting on it. The DOM removal happens regardless.

How is onError different from try/catch in onWillStart? +

try/catch in onWillStart catches errors from that specific async hook in that specific component. onError is an error boundary β€” it catches errors thrown by any descendant component's lifecycle hooks, renders, or unhandled Promises during OWL's rendering. Use try/catch for expected errors in a specific operation. Use onError when you want a parent to act as a safety net for an entire subtree β€” hiding failure from the user and showing a fallback.