Preventing Alpine.js Memory Leaks with destroy() Cleanup

Preventing Alpine.js Memory Leaks with destroy() Cleanup

~ 4 min read


Alpine components are easy to write, but they are also easy to leak.

The usual failure mode is straightforward: init() gets called more than once for the same logical UI area (it shouldn’t but I’ve seen it happen), and each call adds listeners, observers, or poll loops that are never removed. You do not notice at first. Then counters animate twice, tabs start behaving oddly, and background polling keeps running after the component is gone.

This post shows a practical clean-up pattern that fixed those problems across multiple Alpine components in one pass.

Where the leaks came from

There were four repeated patterns:

  1. document.addEventListener("stats.update", ...) in several components
  2. window.addEventListener("hashchange", ...) in tab components
  3. ResizeObserver instances attached to body or .site-header
  4. Recursive setTimeout polling loops for comments and emails

Any one of these can leak. Combined, they multiply quickly if components re-initialise.

Define lifecycle state explicitly

First, make lifecycle state part of the component contract. If you cannot see what must be cleaned up, you will not clean it up.

interface TabsContext {
    windowObserver: ResizeObserver | null;
    windowObserverTarget: HTMLElement | null;
    hashChangeHandler: (() => void) | null;
    statsUpdateHandler: ((e: CustomEvent<StatsEventDTO>) => void) | null;
    init(): Promise<void>;
    destroy(): void;
}

The important change is not just destroy(). It is storing references for anything you need to detach later.

Make init() idempotent

For shared stores and long-lived components, call destroy() at the top of init(). This protects you from accidental double setup.

init() {
    this.destroy();
    document.addEventListener(
        "stats.update",
        this.handleStatsUpdateEvent as EventListener
    );
    document.addEventListener(
        "feed.change",
        this.handleFeedChangeEvent as EventListener
    );
}

Idempotent init is one of the highest-value guardrails for Alpine lifecycle issues.

Keep stable handler references

Inline anonymous callbacks are convenient, but you cannot remove what you did not keep.

this.statsUpdateHandler = (e: CustomEvent<StatsEventDTO>) => {
    // update totals and animations
};
document.addEventListener(
    "stats.update",
    this.statsUpdateHandler as EventListener,
);

Then clean up using the same reference:

destroy() {
    if (this.statsUpdateHandler) {
        document.removeEventListener(
            "stats.update",
            this.statsUpdateHandler as EventListener
        );
        this.statsUpdateHandler = null;
    }
}

This same approach works for hashchange, scroll, resize, and custom events.

Clean up observers with both unobserve and disconnect

ResizeObserver cleanup is more robust when you keep both the observer and target references.

this.windowObserver = new ResizeObserver((entries) => {
    this.modalOpen = entries[0].contentRect.width >= 976;
});
this.windowObserverTarget = document.querySelector<HTMLElement>(".site-header");
if (this.windowObserverTarget) {
    this.windowObserver.observe(this.windowObserverTarget);
}

And in destroy():

if (this.windowObserver && this.windowObserverTarget) {
    this.windowObserver.unobserve(this.windowObserverTarget);
}
if (this.windowObserver) {
    this.windowObserver.disconnect();
    this.windowObserver = null;
}
this.windowObserverTarget = null;

This also avoids strict typing hacks like non-null assertions on selectors that may not exist.

Treat polling loops as cancellable resources

Polling loops were converted to return clean-up functions, then those functions were stored in a shared Alpine store.

export function pollForNewComments(...): () => void {
    let timeoutId: number | null = null;
    let isRunning = true;

    const cleanup = () => {
        isRunning = false;
        if (timeoutId !== null) {
            clearTimeout(timeoutId);
            timeoutId = null;
        }
    };

    // schedule poll...
    return cleanup;
}

The store keeps and clears them when the mode changes or when the store is destroyed:

if (this.commentsPollCleanup) {
    this.commentsPollCleanup();
    this.commentsPollCleanup = null;
}
if (this.emailsPollCleanup) {
    this.emailsPollCleanup();
    this.emailsPollCleanup = null;
}

This is the same lifecycle model as event listeners and observers: register, hold reference, release.

Verify the fix

Use quick checks while repeatedly opening, closing, or reloading the same UI:

  1. In Chrome DevTools, check listener counts: getEventListeners(document)["stats.update"]?.length ?? 0
  2. Switch tabs and reopen components several times.
  3. Confirm listener counts stabilise instead of increasing.
  4. In the Memory panel, compare heap snapshots before and after repeated component cycles.

If counts keep climbing, you still have a missing clean-up path.

Practical checklist

When adding any Alpine component that subscribes to an external state:

  1. Add destroy() to the context/interface on day one.
  2. Store handler references (fooHandler) instead of inline callbacks.
  3. Keep observer target references (fooTarget) for safe unobserve.
  4. Return cleanup functions from timers/pollers and store them.
  5. Call destroy() before init() when re-inits are possible.

If you follow this consistently, Alpine remains lightweight without accumulating invisible lifecycle bugs.

all posts →