Create your own
Lesson illustration

Automatic Listener Cleanup with EventBus

Hello and welcome back!

In our last lesson, we significantly enhanced our EventBus by implementing the observe method. This method acts as a reactive factory, returning a type-safe Observable for a specific event by leveraging our filter and map operators on an internal Subject.

However, as we briefly touched upon, there's a critical piece of the puzzle missing: resource management. This brings us to today's learning outcome: to ensure the EventBus automatically cleans up its internal listeners when the returned observable is unsubscribed.

This lesson is all about preventing memory leaks and building a robust, production-ready library. An observable that doesn't clean up after itself can cause significant issues in a long-running application.

1. The Problem: Dangling Subscriptions and Memory Leaks

When a consumer subscribes to an observable, a connection is made. If that connection isn't severed when the consumer is no longer interested, it remains active, consuming memory and CPU cycles unnecessarily. This is a classic memory leak.

In the context of our EventBus, every time observe('some-event').subscribe(...) is called, a new listener is attached to our internal Subject. If the consumer moves on without unsubscribing, that listener dangles, still attached to the Subject, waiting for events it will never process for a consumer that no longer exists.

To see a practical demonstration of this problem in a real-world framework, watch the first part of the following video. While it uses Angular as its context, the core principle of a lingering subscription causing a memory leak is universal to reactive programming.

Angular unsubscribe, Angular async pipe, RxJS subscribe - Avoid Memory Leaks

The video 'Angular unsubscribe... - Avoid Memory Leaks' from Monsterlessons Academy clearly illustrates why unmanaged subscriptions are a problem. Focus on the demonstration of how an interval subscription continues to run even after the component that created it is destroyed.

Watch the segment from the beginning until 01:47. Note how the console.log from the subscription continues even after navigating away from the page where the subscription was created.

As you saw, failing to unsubscribe leaves the stream active in the background. In our EventBus, this would mean that our internal Subject would accumulate an ever-growing list of "zombie" listeners, a clear memory leak.

2. The Solution: The Power of Teardown Logic

Fortunately, our Observable class was designed with this exact problem in mind back in Module 2. Recall the signature of the Observable constructor:

new Observable(producer)

The producer function you pass to it receives an observer and, crucially, is expected to return a teardown function. This teardown function is the magic bullet for resource cleanup. It is automatically executed when a consumer calls .unsubscribe() on the Subscription object they received.

Our task is to ensure the observe method creates an Observable with the correct teardown logic.

3. Refactoring observe for Automatic Cleanup

In the last lesson, our observe method looked like this:

// Previous version
observe<K extends keyof EventMap>(event: K): Observable<EventMap[K]> {
  return this.subject.asObservable().pipe(
    filter((e): e is Extract<BusEvent<EventMap>, { type: K }> => e.type === event),
    map(e => e.payload)
  );
}

While concise, this relies on our pipe implementation correctly chaining the unsubscription logic all the way through. To make the cleanup mechanism explicit and reinforce the core observable pattern, we will refactor this method to use the new Observable constructor directly.

This approach makes the relationship between the consumer's unsubscription and the internal cleanup action unambiguous.

// src/EventBus.ts (updated observe method)
import { Observable } from './Observable';
import { filter, map } from './operators';
// ... other imports

// ... EventBus class definition
  
  observe<K extends keyof EventMap>(event: K): Observable<EventMap[K]> {
    // We return a new Observable that wraps the entire logic.
    return new Observable(observer => {
      // 1. We create the subscription to our internal, piped stream.
      //    We forward all notifications (next, error, complete) from the
      //    inner stream to the observer that was passed to us.
      const subscription = this.subject
        .asObservable()
        .pipe(
          filter((e): e is Extract<BusEvent<EventMap>, { type: K }> => e.type === event),
          map(e => e.payload)
        )
        .subscribe(observer); // Pass the consumer's observer here.

      // 2. We return the teardown logic. This function will be called
      //    when the consumer of `bus.observe(...).subscribe(...)`
      //    calls .unsubscribe() on their subscription.
      return () => {
        console.log(`Unsubscribing from internal subject for event: ${String(event)}`); // For demonstration
        subscription.unsubscribe();
      };
    });
  }

// ... rest of the class

Let's break down this powerful pattern:

  1. Wrapping in new Observable: We create a new "wrapper" observable that defines a custom subscription and unsubscription behavior.
  2. The Producer Logic: When a consumer subscribes to the observable returned by observe, our producer function runs. It subscribes to the filtered-and-mapped stream from the internal subject. The crucial part is subscribe(observer), which forwards all emissions to the end consumer.
  3. The Teardown Logic: The producer returns a function: () => subscription.unsubscribe(). When the consumer unsubscribes, our Observable implementation invokes this function, which in turn unsubscribes from the internal subject, severing the connection and preventing any leaks.

This pattern is fundamental to reactive libraries. The article "From RxJS to 𝗥𝘅𝑓𝑥" discusses a similar idea.

From RxJS to 𝗥𝘅𝑓𝑥, building an Event Bus for reliable ...

This article discusses the importance of unregistering listeners and how the RxJS Subscription object provides a clean API for this.

Read the section titled 'Will You Stop Listening, Already?'. Notice how it praises the RxJS pattern of returning a Subscription object with an unsubscribe method as a clean way to manage listener lifecycles. Our implementation achieves the same goal by returning an Observable that produces such a subscription.

4. Proving it with a Unit Test

As an experienced developer, you know that a feature isn't complete until it's tested. How can we verify that our cleanup logic works? We can write a unit test that asserts that the number of listeners on our internal Subject goes back to zero after unsubscription.

First, to make this testable, we need a small addition to our Subject class from Module 3. Let's add a public getter to see how many observers are currently subscribed. This is a common pattern for testing internal state in libraries.

// src/Subject.ts (addition)

export class Subject<T> extends Observable<T> {
  private observers: Set<Observer<T>> = new Set();
  
  // ... existing methods: next, error, complete, asObservable, subscribe

  /**
   * (For testing purposes) Returns the number of currently subscribed observers.
   */
  public get observerCount(): number {
    return this.observers.size;
  }
}

Now, we can write our test for the EventBus.

// src/EventBus.test.ts
import { describe, it, expect } from 'vitest';
import { EventBus } from './EventBus';
import { Subject } from './Subject'; // We need to import it to spy on it

describe('EventBus', () => {
  interface TestEvents {
    'test:event': string;
    'other:event': number;
  }

  // ... other tests for emit, observe, etc.

  it('should automatically clean up internal listeners on unsubscription', () => {
    const bus = new EventBus<TestEvents>();
    
    // Access the private subject for inspection.
    // In TypeScript, `bus['subject']` is a way to access private properties in tests.
    const internalSubject = (bus as any).subject as Subject<any>;

    // 1. Initially, there should be no observers on the internal subject.
    expect(internalSubject.observerCount).toBe(0);

    // 2. Subscribe to an observable from the bus.
    const subscription = bus.observe('test:event').subscribe({
      next: () => {},
    });

    // 3. Now, there should be one observer on the internal subject.
    expect(internalSubject.observerCount).toBe(1);

    // 4. Emit an event on a different channel; the observer count should not change.
    bus.emit('other:event', 123);
    expect(internalSubject.observerCount).toBe(1);

    // 5. Unsubscribe from the observable.
    subscription.unsubscribe();

    // 6. The observer should be removed from the internal subject.
    expect(internalSubject.observerCount).toBe(0);
  });
});

This test provides concrete proof that our teardown logic is working as intended. It confirms that subscribing adds a listener and, most importantly, unsubscribing removes it, preventing memory leaks.

Test your understanding!

Consider a scenario where a consumer subscribes to the same event observable twice:

const bus = new EventBus<AppEvents>();
const userLogin$ = bus.observe('user:login');

const sub1 = userLogin$.subscribe(console.log);
const sub2 = userLogin$.subscribe(console.error);

How many observers would our internal Subject have at this point? What happens when sub1.unsubscribe() is called?

Show answer

The internal Subject would have two observers.

Each call to .subscribe() on the userLogin$ observable executes the producer function we defined within observe anew. This means two separate inner subscriptions to the Subject are created.

When sub1.unsubscribe() is called, the teardown logic for the first subscription is triggered. This will remove one of the observers from the internal Subject. The second observer, belonging to sub2, will remain active until sub2.unsubscribe() is called. This is the correct and expected behavior, ensuring that multiple independent listeners don't interfere with each other.

Conclusion

Congratulations! You have now fortified the EventBus against memory leaks, one of the most common pitfalls in reactive programming. By explicitly defining teardown logic within a custom Observable, you've created a robust and reliable API that cleans up after itself automatically.

Key Takeaways:

  • The Teardown Pattern: The return () => { ... } function within an Observable's producer is the canonical mechanism for handling resource cleanup (e.g., closing WebSockets, clearing intervals, or unsubscribing from other observables).
  • Encapsulation: Our observe method successfully encapsulates the complexity of subscribing and unsubscribing from the internal Subject, providing the consumer with a simple, safe Observable interface.
  • Testability: Exposing internal state for testing purposes (like observerCount) is a valid and often necessary practice when building libraries.

Our EventBus is now complete, type-safe, and memory-safe. This concludes Module 7.

In the next module, "Advanced Testing & RxJS Interoperability," we will elevate our testing game. We'll start by building a TestScheduler to deterministically test complex, time-based asynchronous observable chains, moving beyond setTimeout and fakeTimers to a more powerful testing paradigm inspired by RxJS.

Can't find a good explanation? Sign up and we'll make it for you

Sign up