Subscription Lifecycle & Memory Management
The Newsletter Analogy
Think of event subscriptions like email newsletter subscriptions:
- Subscribe: You give your email to start receiving updates
- Unsubscribe: You click "unsubscribe" to stop receiving emails
- Auto-cleanup: Good services remove your email from their database when you unsubscribe
Traditional event buses have a memory leak problem:
// ❌ Memory leak: Listener never cleaned up
class Component {
constructor() {
bus.on('data:update', this.handleUpdate.bind(this));
}
destroy() {
// Component destroyed, but listener still exists!
// Memory leak: Component can't be garbage collected
}
handleUpdate(data) {
this.updateUI(data);
}
}Nexus provides explicit cleanup:
// ✅ Proper cleanup: Listener removed when component destroyed
class Component {
private subscription: Subscription;
constructor() {
this.subscription = bus.on('data:update', this.handleUpdate.bind(this));
}
destroy() {
this.subscription.unsubscribe(); // 👈 Cleanup
}
handleUpdate(data) {
this.updateUI(data);
}
}The Problem: Zombie Listeners
In modern web applications, components are created and destroyed frequently:
- Single Page Apps (SPA): Navigation destroys/creates components
- React/Vue/Angular: Components mount and unmount
- Modal dialogs: Open, close, open again
- Lazy loading: Components load and unload on demand
Without cleanup, listeners accumulate:
t=0s → User opens modal → Subscribe to 'data:update'
t=5s → User closes modal → Component destroyed but listener remains
t=10s → User opens modal again → Second subscription added
t=15s → User closes modal → Two listeners remain
t=20s → User opens modal again → Third subscription added
Result: 3 listeners for destroyed components!Each dead listener:
- Wastes memory
- Still executes (errors or unexpected behavior)
- Prevents garbage collection
- Slows down emit operations
The Solution: Subscription Objects
Nexus returns a Subscription object from .on() with an unsubscribe() method:
const subscription = bus.on('event:name', handler);
// Later, when done
subscription.unsubscribe();This ensures:
- ✅ Listeners are properly removed
- ✅ Memory is freed for garbage collection
- ✅ Components don't respond to events after destruction
- ✅ No accidental execution on stale references
Basic Example: Component Lifecycle
import { Nexus, Subscription } from '@caeligo/nexus-orchestrator';
const bus = new Nexus();
class UserProfile {
private subscription: Subscription;
private element: HTMLElement;
constructor(userId: string) {
this.element = document.createElement('div');
this.element.id = 'user-profile';
// Subscribe to user updates
this.subscription = bus.on('user:update', (data) => {
if (data.userId === userId) {
this.render(data);
}
});
// Initial load
this.loadUser(userId);
}
render(userData: any) {
this.element.innerHTML = `
<h2>${userData.name}</h2>
<p>${userData.email}</p>
`;
}
async loadUser(userId: string) {
const data = await fetch(`/api/users/${userId}`).then(r => r.json());
this.render(data);
}
destroy() {
// Critical: Remove listener before destroying component
this.subscription.unsubscribe();
this.element.remove();
}
}
// Usage
const profile = new UserProfile('user-123');
document.body.appendChild(profile.element);
// Later, when navigating away
profile.destroy(); // CleanupReal-World Example: React Component
import { useEffect } from 'react';
import { bus } from './eventBus';
function NotificationBanner() {
const [notifications, setNotifications] = useState<string[]>([]);
useEffect(() => {
// Subscribe when component mounts
const subscription = bus.on('notification:new', (data) => {
setNotifications(prev => [...prev, data.message]);
});
// Cleanup when component unmounts
return () => {
subscription.unsubscribe();
};
}, []); // Empty deps = mount/unmount only
return (
<div className="notifications">
{notifications.map((msg, i) => (
<div key={i} className="notification">{msg}</div>
))}
</div>
);
}Without cleanup:
// ❌ Memory leak
useEffect(() => {
bus.on('notification:new', handler);
// No cleanup!
}, []);Result: Every time the component re-renders, a new listener is added. Opening/closing the component 10 times creates 10 listeners.
Real-World Example: Vue Component
<script setup lang="ts">
import { onMounted, onUnmounted } from 'vue';
import { bus } from './eventBus';
import type { Subscription } from '@caeligo/nexus-orchestrator';
const messages = ref<string[]>([]);
let subscription: Subscription;
onMounted(() => {
subscription = bus.on('chat:message', (data) => {
messages.value.push(data.text);
});
});
onUnmounted(() => {
subscription.unsubscribe(); // Cleanup
});
</script>
<template>
<div class="chat">
<div v-for="msg in messages" :key="msg">{{ msg }}</div>
</div>
</template>Real-World Example: Angular Component
import { Component, OnDestroy } from '@angular/core';
import { bus } from './event-bus';
import type { Subscription } from '@caeligo/nexus-orchestrator';
@Component({
selector: 'app-dashboard',
template: `<div>{{ status }}</div>`
})
export class DashboardComponent implements OnDestroy {
status: string = 'Loading...';
private subscription: Subscription;
constructor() {
this.subscription = bus.on('api:status', (data) => {
this.status = data.message;
});
}
ngOnDestroy() {
this.subscription.unsubscribe(); // Cleanup
}
}Subscription Patterns
Pattern 1: Single Subscription
class Component {
private subscription: Subscription;
constructor() {
this.subscription = bus.on('event', handler);
}
destroy() {
this.subscription.unsubscribe();
}
}Pattern 2: Multiple Subscriptions
class Component {
private subscriptions: Subscription[] = [];
constructor() {
this.subscriptions.push(
bus.on('event:a', handlerA),
bus.on('event:b', handlerB),
bus.on('event:c', handlerC)
);
}
destroy() {
// Unsubscribe all
this.subscriptions.forEach(sub => sub.unsubscribe());
this.subscriptions = [];
}
}Pattern 3: Conditional Subscription
class Component {
private subscription: Subscription | null = null;
enableFeature() {
// Subscribe only when feature is enabled
if (!this.subscription) {
this.subscription = bus.on('feature:event', handler);
}
}
disableFeature() {
// Unsubscribe when feature is disabled
if (this.subscription) {
this.subscription.unsubscribe();
this.subscription = null;
}
}
destroy() {
this.disableFeature(); // Ensure cleanup
}
}One-Time Subscriptions: .once()
For events that should only trigger once, use .once():
// Listen once, then auto-unsubscribe
bus.once('app:init', (config) => {
console.log('App initialized:', config);
// Listener automatically removed after first execution
});
bus.emit('app:init', { version: '1.0' });
// Logs: App initialized: { version: '1.0' }
bus.emit('app:init', { version: '2.0' });
// Does NOT log - listener already removedHow it works:
// Simplified implementation
once(event: string, fn: Listener) {
const subscription = this.on(event, (payload) => {
subscription.unsubscribe(); // Remove itself
fn(payload); // Then execute
});
return subscription.unsubscribe; // Return cleanup function
}Real-world use case:
class LoginPage {
constructor() {
// Wait for user to log in (only care about first login)
bus.once('auth:login', (user) => {
this.redirectToHome(user);
// No need to unsubscribe - already done automatically
});
}
}Memory Management: What Happens on Unsubscribe
When you call unsubscribe(), Nexus performs several cleanup operations:
unsubscribe() {
// 1. Get the listener set for this event
const listeners = this.listeners.get(eventName);
// 2. Remove the specific listener
listeners.delete(listener);
// 3. If no listeners remain, remove the entire entry
if (listeners.size === 0) {
this.listeners.delete(eventName);
}
// 4. Allow garbage collection
// (listener and closures can now be freed)
}Memory freed:
- Listener function reference
- Closure scope (captured variables)
- Listener options object
- Entry in Map (if last listener)
Edge Case: Unsubscribing Multiple Times
Calling unsubscribe() multiple times is safe (idempotent):
const subscription = bus.on('event', handler);
subscription.unsubscribe(); // First call: removes listener
subscription.unsubscribe(); // Second call: no-op (safe)
subscription.unsubscribe(); // Third call: still no-opNo errors are thrown. The listener is simply not present anymore.
Edge Case: Unsubscribing During Execution
You can unsubscribe inside the handler itself:
const subscription = bus.on('event', (data) => {
console.log('Handling:', data);
if (data.shouldStop) {
subscription.unsubscribe(); // Unsubscribe inside handler
}
});
bus.emit('event', { shouldStop: false }); // Logs: Handling: { shouldStop: false }
bus.emit('event', { shouldStop: true }); // Logs: Handling: { shouldStop: true }
bus.emit('event', { shouldStop: false }); // Does NOT log - already unsubscribedThis is safe and commonly used for self-limiting listeners.
Edge Case: Wildcard Unsubscription
Wildcard subscriptions unsubscribe the same way:
const subscription = bus.on('user:*', handler);
// Later
subscription.unsubscribe(); // Removes wildcard listener
// No more user:* events trigger this handler
bus.emit('user:login', {}); // Handler NOT called
bus.emit('user:logout', {}); // Handler NOT calledPerformance: Why Cleanup Matters
Scenario: A modal that opens/closes repeatedly without cleanup:
// ❌ BAD: No cleanup
class Modal {
constructor() {
bus.on('data:update', this.render.bind(this));
}
render(data) {
// Update modal UI
}
}
// User opens/closes modal 100 times
for (let i = 0; i < 100; i++) {
const modal = new Modal();
// Modal closed but listener remains
}
// Result: 100 listeners for 'data:update'
bus.emit('data:update', data);
// ^ Executes 100 handlers (99 are dead components)Impact:
emit()execution time increases linearly with dead listeners- Memory usage grows continuously
- Application slows down over time
- Potential errors from handlers on destroyed components
With cleanup:
// ✅ GOOD: Proper cleanup
class Modal {
private subscription: Subscription;
constructor() {
this.subscription = bus.on('data:update', this.render.bind(this));
}
render(data) {
// Update modal UI
}
destroy() {
this.subscription.unsubscribe();
}
}
// User opens/closes modal 100 times
for (let i = 0; i < 100; i++) {
const modal = new Modal();
modal.destroy(); // Cleanup
}
// Result: 0 listeners (all cleaned up)
bus.emit('data:update', data);
// ^ Executes 0 handlers (no waste)Testing: Verifying Cleanup
When writing tests, verify that components clean up properly:
import { describe, it, expect } from 'vitest';
describe('Component Lifecycle', () => {
it('should unsubscribe on destroy', () => {
let callCount = 0;
const component = new MyComponent();
// Emit event
bus.emit('test:event', {});
expect(callCount).toBe(1); // Handler called
// Destroy component
component.destroy();
// Emit again
bus.emit('test:event', {});
expect(callCount).toBe(1); // Handler NOT called (unsubscribed)
});
});Best Practices
✅ DO: Store subscriptions in properties
class Component {
private subscription: Subscription;
constructor() {
this.subscription = bus.on('event', handler);
}
destroy() {
this.subscription.unsubscribe();
}
}❌ DON'T: Ignore subscription return value
class Component {
constructor() {
bus.on('event', handler); // ❌ Lost reference!
}
destroy() {
// Can't unsubscribe - no reference!
}
}✅ DO: Use framework lifecycle hooks
// React
useEffect(() => {
const sub = bus.on('event', handler);
return () => sub.unsubscribe();
}, []);
// Vue
onUnmounted(() => subscription.unsubscribe());
// Angular
ngOnDestroy() { this.subscription.unsubscribe(); }❌ DON'T: Forget cleanup in class components
class Component {
componentDidMount() {
bus.on('event', handler); // ❌ No cleanup!
}
// Missing componentWillUnmount
}✅ DO: Group related subscriptions
class Component {
private subscriptions: Subscription[] = [];
constructor() {
this.subscriptions = [
bus.on('event:a', handlerA),
bus.on('event:b', handlerB),
bus.on('event:c', handlerC)
];
}
destroy() {
this.subscriptions.forEach(sub => sub.unsubscribe());
}
}✅ DO: Use .once() for one-time events
// ✅ Auto-cleanup
bus.once('app:init', handler);
// Instead of:
const sub = bus.on('app:init', (data) => {
sub.unsubscribe(); // Manual cleanup
handler(data);
});Debugging: Finding Memory Leaks
To find missing cleanup, use browser DevTools:
// Add to development builds
if (process.env.NODE_ENV === 'development') {
// @ts-ignore - Access internal state
window.__nexus_listeners = bus.listeners;
// In console:
// __nexus_listeners
// ^ Shows all active listeners
}Look for:
- Events with many listeners (> 10)
- Listeners that grow over time
- Components that should be destroyed but still have listeners
Comparison with Other Event Systems
| System | Cleanup Method | Auto-cleanup | Risk |
|---|---|---|---|
| Nexus | .unsubscribe() | .once() only | Manual cleanup required |
| Node EventEmitter | .removeListener() | No | Manual cleanup required |
| RxJS | .unsubscribe() | Some operators | Manual cleanup required |
| Vue Event Bus | $off() | No | Manual cleanup required |
| React Synthetic Events | None (auto) | Yes | DOM-only |
Key insight: Most event systems require manual cleanup. Nexus is consistent with industry standards.
When Cleanup Happens Automatically
Nexus cleans up automatically in these cases:
.once()listeners: Auto-remove after first execution.request()listeners: Auto-remove after reply or timeout- Cross-tab sync: Cleanup when BroadcastChannel closes
All other subscriptions require manual cleanup.
Next Steps
Now that you understand subscription lifecycle, explore how cleanup interacts with other features:
- Event Replay - Replay respects subscription lifecycle
- Wildcard Patterns - Wildcards follow same cleanup rules
- RPC Pattern - Request/reply has automatic cleanup
Lifecycle Checklist
When creating components with Nexus subscriptions, ask:
- [ ] Do I store the subscription object?
- [ ] Do I call
unsubscribe()when the component is destroyed? - [ ] If using a framework, do I use the proper lifecycle hook?
- [ ] Could I use
.once()instead for one-time events? - [ ] Am I creating multiple subscriptions that should be grouped?
- [ ] Have I tested that cleanup actually works?
- [ ] Are there any edge cases where the component might not call
destroy()?
Proper subscription lifecycle management is critical for preventing memory leaks and ensuring application performance.
