Vue Event Bus Listener Leaks

A global event bus makes cross-component communication easy — and after users navigate around for a while, each emit triggers dozens of handlers from components that were unmounted long ago, and heap snapshots show those components’ instances retained through the bus. This guide from Vue Reactivity and Memory Management, part of Framework-Specific Memory Optimization, explains why event-bus subscriptions leak, why off() often silently fails, and how to scope subscriptions so they cannot outlive their components.

Symptom Root Cause Immediate Action Measurable Impact
Handlers run for components no longer on screen emitter.on() in mount, no off() on unmount Unsubscribe in onUnmounted/onScopeDispose One handler per mounted instance
off() called but handler still registered Different function reference passed to off() (inline arrow) Keep the same function reference for on/off Removal actually happens
Bus handler list length grows with navigation Every mount adds a handler Log emitter.all.get(type)?.length in development Visible, testable leak signal
Unmounted component instances retained Handler closure captured component state and proxies Remove handler; avoid capturing large state Component instance collectable
Errors from handlers touching destroyed DOM Handler runs after unmount Same fix: unsubscribe on unmount No stale handler errors

Root Cause: The Bus Outlives Every Subscriber

Vue 3 removed the instance event API ($on, $off, $once) that Vue 2 apps often used as an event bus, so projects typically use a tiny emitter such as mitt, Node-style EventEmitter ports, or a hand-rolled Map<string, Set<Function>>. The emitter is created once at module level and imported everywhere. That makes it a GC root for the whole session: every handler registered on it stays reachable until explicitly removed — the same shape as module-level caches and global singleton leaks.

A handler registered from a component is almost always a closure over that component’s setup scope: refs, reactive state, props, emitted functions, sometimes template refs to DOM elements. If the component registers in onMounted and never unregisters, unmounting does not remove the handler. The bus now retains the closure, the closure retains the component’s reactive state and proxies, and — through template refs — detached DOM. Each navigation back to the view adds another handler, so both memory and the cost of each emit grow with session length.

The most common “but I do call off()” bug is function identity. emitter.on('saved', () => refresh()) followed by emitter.off('saved', () => refresh()) passes a different arrow function to off(); the emitter compares by reference, finds nothing, and silently leaves the original registered. The fix is to keep the handler in a variable and pass the same reference to both calls — or better, to wrap subscription in a composable that returns and registers its own cleanup via onScopeDispose, as shown in using effectScope for Vue cleanup.

Handlers accumulate on a long-lived bus A module-level mitt emitter holds a handler list for the order-saved event. It contains six handlers: five from OrderPanel instances that have been unmounted and one from the currently mounted instance. Each stale handler is a closure retaining its component's refs, reactive state and template DOM, so every emit runs six handlers and memory grows with every navigation. bus (mitt) module scope 'order:saved' → [6] handler · OrderPanel #1 (gone) handler · OrderPanel #2 (gone) handler · OrderPanel #3 (gone) handler · OrderPanel #4 (gone) handler · OrderPanel #5 (gone) handler · OrderPanel #6 (mounted) each stale closure refs, reactive state, props, template DOM stay reachable

Step-by-Step Fix

  1. Instrument the bus in development. Log the handler count per event type after navigation — with mitt, bus.all.get('order:saved')?.length. Verification: the count grows as you navigate to and away from the view.
  2. Find subscriptions without cleanup. Search for bus.on( / emitter.on( in components and composables, and check each has a matching removal in onUnmounted or onScopeDispose. Verification: you have a list of unmatched subscriptions.
  3. Fix handler identity. Store the handler in a constant and pass the same reference to on and off. Verification: after unmount, the handler count drops by one.
  4. Wrap subscriptions in a composable. Provide useBusEvent(type, handler) that subscribes and registers its own cleanup with onScopeDispose. Verification: components no longer call bus.on directly.
  5. Prefer narrower channels. Replace global events with props/emits, provide/inject or a store where possible, so fewer long-lived subscriptions exist. Verification: the number of bus event types and subscribers shrinks.
  6. Re-test navigation. Navigate to the view and away twenty times and check the handler count and a heap snapshot. Verification: the count equals the number of mounted subscribers and no detached component DOM remains.
Handlers per event across 30 navigations Before the fix, the order-saved event has one more handler after every navigation to the order view, reaching 30. After subscribing through a composable that unsubscribes on scope dispose, the count stays at 1. 30 0 on() without matching off() useBusEvent with onScopeDispose navigations to the order view (0 → 30)

Command and Code Reference

Use case: the identity bug and its fix.

// Leaky: off() receives a different function, so nothing is removed
onMounted(() => bus.on('order:saved', () => refresh()));
onUnmounted(() => bus.off('order:saved', () => refresh()));

// Fixed: one reference for both calls
const onSaved = () => refresh();
onMounted(() => bus.on('order:saved', onSaved));
onUnmounted(() => bus.off('order:saved', onSaved));

Use case: a composable that makes the right thing automatic.

// useBusEvent.js
import { onScopeDispose, getCurrentScope } from 'vue';
import { bus } from './bus';

export function useBusEvent(type, handler) {
  bus.on(type, handler);
  const stop = () => bus.off(type, handler);   // same reference, guaranteed
  if (getCurrentScope()) onScopeDispose(stop); // component or explicit effect scope
  return stop;                                 // callers outside scopes can stop manually
}

// In a component:
// useBusEvent('order:saved', (order) => refresh(order.id));

Use case: a development check for runaway handler lists.

// bus.js
import mitt from 'mitt';
export const bus = mitt();

if (import.meta.env.DEV) {
  const originalOn = bus.on;
  bus.on = (type, handler) => {
    originalOn(type, handler);
    const n = bus.all.get(type)?.length ?? 0;
    if (n > 10) console.warn(`[bus] ${String(type)} has ${n} handlers — missing off()?`);
  };
}

Verification and Regression Prevention

A bus leak is fixed when the handler count for each event equals the number of currently mounted subscribers after any amount of navigation, and heap snapshots show no retained component instances or detached DOM reachable through the emitter’s handler arrays. Check both the count and the snapshot: a correct count with lingering instances means another path is retaining them.

Keep the development warning in place and add a unit test for the composable that mounts and unmounts a test component twenty times, asserting the handler count returns to zero. In code review, direct bus.on calls in components should be replaced by useBusEvent. For subscriptions to stores rather than buses, the same principles appear in Zustand and MobX subscription leaks.

Handler count versus retained instances Check both the handler count and the snapshot. If handlers per event equal mounted subscribers and no component instances are reachable through the emitter, the leak is fixed. A higher count means some component registers without off in onUnmounted. A correct count with lingering instances means another path, not the bus, retains them. Handlers per event after navigation Fixed: bus is clean = mounted, no instances A component calls on() without off() in onUnmounted count too high Another retainer; follow the snapshot path count right, instances stay

Edge Cases and Gotchas

Wildcard handlers

bus.on('*', handler) receives every event and is often added for logging or analytics. It is easy to forget, and it keeps whatever it captures for the whole session. Scope it like any other subscription.

Once handlers that never fire

A handler registered to run once removes itself only when the event fires. If the event never comes — the save never happens — the handler stays forever. Remove once-handlers on unmount too.

Clearing the whole bus

bus.all.clear() removes every handler, which is useful on logout or in tests but dangerous in running code, because it also removes handlers from components that are still mounted.

Handlers capturing template refs

A handler that reads panelRef.value keeps the DOM element reachable through the component’s scope. Even after fixing unsubscription, avoid storing DOM references in module-level structures reachable from handlers.

Frequently Asked Questions

Why does my Vue event bus handler run multiple times?

Because each mount of the component registered a new handler and unmount never removed it. The handler list accumulates one entry per mount. Unsubscribe on unmount using the same function reference you subscribed with.

Why doesn’t emitter.off remove my handler?

off removes a handler by identity. Passing a new inline arrow function — even with identical code — does not match the registered one. Store the handler in a variable and pass that same variable to both on and off.

Is an event bus a bad idea in Vue 3?

Not inherently, but it creates long-lived, global subscriptions that must be managed carefully. Props and emits, provide/inject and stores cover most communication needs with lifecycle-aware cleanup. Use a bus for genuinely global, cross-cutting events, and wrap subscriptions in a composable.

How do I migrate a Vue 2 event bus safely?

Vue 2 code often used this.$root.$on or a spare Vue instance as a bus and relied on $off in beforeDestroy. When moving to Vue 3 and mitt, replace each pair with the useBusEvent composable so removal is automatic, and search for handlers that were registered in created without any matching removal — those were leaking in Vue 2 as well and are worth fixing during the migration.

What is the performance cost of a leaky bus besides memory?

Every emit runs every registered handler, so a bus that accumulates stale handlers gets slower with each navigation. Stale handlers may also trigger network requests, state updates or errors from destroyed components, which can look like unrelated bugs. Fixing the subscription lifecycle removes all of these symptoms together.

Should stores replace the event bus entirely?

For shared state, yes: a Pinia store gives components reactive access with lifecycle-aware subscriptions, and nothing needs manual removal. Keep a bus only for genuine fire-and-forget notifications, such as a global “session expired” signal, and wrap those subscriptions in the composable.

Can a WeakRef-based bus avoid leaks automatically?

Holding handlers weakly means a handler might disappear while its component is still mounted if nothing else references the closure, which makes events unreliable. Explicit unsubscription tied to the component’s scope is predictable and simple.