Skip to content

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:

typescript
// ❌ 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:

typescript
// ✅ 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:

typescript
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

typescript
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(); // Cleanup

Real-World Example: React Component

typescript
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:

typescript
// ❌ 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

typescript
<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

typescript
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

typescript
class Component {
  private subscription: Subscription;
  
  constructor() {
    this.subscription = bus.on('event', handler);
  }
  
  destroy() {
    this.subscription.unsubscribe();
  }
}

Pattern 2: Multiple Subscriptions

typescript
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

typescript
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():

typescript
// 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 removed

How it works:

typescript
// 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:

typescript
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:

typescript
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):

typescript
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-op

No errors are thrown. The listener is simply not present anymore.

Edge Case: Unsubscribing During Execution

You can unsubscribe inside the handler itself:

typescript
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 unsubscribed

This is safe and commonly used for self-limiting listeners.

Edge Case: Wildcard Unsubscription

Wildcard subscriptions unsubscribe the same way:

typescript
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 called

Performance: Why Cleanup Matters

Scenario: A modal that opens/closes repeatedly without cleanup:

typescript
// ❌ 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:

typescript
// ✅ 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:

typescript
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

typescript
class Component {
  private subscription: Subscription;
  
  constructor() {
    this.subscription = bus.on('event', handler);
  }
  
  destroy() {
    this.subscription.unsubscribe();
  }
}

❌ DON'T: Ignore subscription return value

typescript
class Component {
  constructor() {
    bus.on('event', handler); // ❌ Lost reference!
  }
  
  destroy() {
    // Can't unsubscribe - no reference!
  }
}

✅ DO: Use framework lifecycle hooks

typescript
// 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

typescript
class Component {
  componentDidMount() {
    bus.on('event', handler); // ❌ No cleanup!
  }
  // Missing componentWillUnmount
}
typescript
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

typescript
// ✅ 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:

typescript
// 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

SystemCleanup MethodAuto-cleanupRisk
Nexus.unsubscribe().once() onlyManual cleanup required
Node EventEmitter.removeListener()NoManual cleanup required
RxJS.unsubscribe()Some operatorsManual cleanup required
Vue Event Bus$off()NoManual cleanup required
React Synthetic EventsNone (auto)YesDOM-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:

  1. .once() listeners: Auto-remove after first execution
  2. .request() listeners: Auto-remove after reply or timeout
  3. 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:

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.

Released under the MIT License.