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:
document.addEventListener("stats.update", ...)in several componentswindow.addEventListener("hashchange", ...)in tab componentsResizeObserverinstances attached tobodyor.site-header- Recursive
setTimeoutpolling 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:
- In Chrome DevTools, check listener counts:
getEventListeners(document)["stats.update"]?.length ?? 0 - Switch tabs and reopen components several times.
- Confirm listener counts stabilise instead of increasing.
- 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:
- Add
destroy()to the context/interface on day one. - Store handler references (
fooHandler) instead of inline callbacks. - Keep observer target references (
fooTarget) for safe unobserve. - Return cleanup functions from timers/pollers and store them.
- Call
destroy()beforeinit()when re-inits are possible.
If you follow this consistently, Alpine remains lightweight without accumulating invisible lifecycle bugs.