Recommended resources
The freely available resources this lesson will direct you to.
Hello again! It's great to see you for the final lesson in our module on advanced testing.
In our last session, we created "golden master" tests to prove our operators behave identically to their RxJS counterparts. We've established a very high standard for the functional correctness of our library.
Today, we shift our focus from correctness to robustness and efficiency. A reactive library that works perfectly but slowly consumes all available memory isn't very useful. This brings us to our final learning outcome for this module: Write a test to detect potential memory leaks from unterminated subscriptions.
We'll explore what memory leaks are in the context of observables, how to spot them manually, and, most importantly, how to build an automated test to catch them before they cause problems. This is a critical quality-gate for any library that manages long-lived resources.
1. Understanding Memory Leaks in a Reactive World
In a garbage-collected language like JavaScript, a memory leak occurs when memory is allocated but never released because an unwanted reference to it is accidentally kept somewhere in your application. The garbage collector (GC) can only reclaim memory that is no longer "reachable."
In the context of our library, the most common source of leaks is an un-terminated Subscription. When you subscribe to an observable, you create a Subscription object. This object holds references to the observer (which might contain methods from a component or class) and the producer function. If you never call unsubscribe(), this entire chain of objects can be kept in memory, even if you no longer need them.
To get a practical overview of how these leaks manifest in a web application and why they're so problematic, please watch the following video.
How To Detect Memory Leaks In Your Web App (2025)
The video 'How To Detect Memory Leaks In Your Web App' from the Decoded Frontend channel provides an excellent explanation of memory management in JavaScript and demonstrates common leak patterns, including those from RxJS subscriptions.
Please watch the first section, 'Understanding Memory Leaks in JavaScript' (00:38 - 04:17), to get a solid conceptual foundation. Then, watch 'Detecting Leaks with Performance Tab' (06:09 - 08:36) to see how these leaks appear as a 'sawtooth' pattern of ever-increasing memory usage.
The key takeaway is that unterminated subscriptions, timers, or event listeners create references that prevent the GC from cleaning up, leading to a gradual increase in memory consumption that can slow down or crash your application.
2. Manual Leak Detection with Chrome DevTools
Before we automate the process, it's valuable to understand how to manually hunt for memory leaks using browser developer tools. This is an essential skill for debugging complex front-end applications. The process generally involves comparing memory snapshots before and after an action that should, in theory, clean up after itself.
The video you just watched also demonstrates this process beautifully.
How To Detect Memory Leaks In Your Web App (2025)
Let's continue with the same video to see how to use the Memory tab in Chrome DevTools to pinpoint the source of a leak.
Please watch the sections 'In-depth Memory Analysis with Memory Tab' (09:31 - 12:37), 'Identifying Retainers to Find Leak Source' (12:22 - 14:01), and 'Comparing Heap Snapshots to Confirm Leaks' (14:01 - 17:31). Focus on the methodology: take a snapshot, perform an action, take another snapshot, and compare them to find objects that were created but not destroyed.
This manual workflow is powerful. By comparing heap snapshots, you can see if instances of your Subscription or Observer classes are accumulating unexpectedly. The "Retainers" view is the crucial last step, showing you exactly what chain of references is keeping an object alive.
3. An Automated Test for Subscription Leaks
While manual inspection is a great debugging tool, it's not a practical way to ensure your library remains leak-free as it evolves. We need an automated test that runs with our test suite.
The core idea is to track the number of active subscriptions. In a well-behaved test, this number should return to zero after all observables have completed or been unsubscribed. How can we do this without modifying our production code? The answer is a technique called monkey-patching. We can temporarily replace the subscribe and unsubscribe methods on our core Observable and Subscription prototypes during a test run to increment and decrement a counter.
This exact challenge is discussed in detail on Stack Overflow. The community has devised some clever solutions.
Monitoring RxJS subscriptions application wide
This Stack Overflow thread, 'Monitoring RxJS subscriptions application wide', directly addresses our goal. It discusses the pitfalls of a naive approach and provides a much more robust solution.
First, read the initial question to understand the problem. Then, focus on the accepted answer by 'maxime1992', which provides a corrected implementation for wrapping subscribe. Notice how it correctly handles complete and error notifications, as these also terminate a subscription. Finally, read 'Picci's answer, which highlights the importance of the closed property on a Subscription instance.
4. Implementing the Leak Detector
Let's use the insights from these resources to build our own subscription leak detector. We'll create a helper for our test environment.
Create a new file: src/testing/subscription-tracker.ts.
// src/testing/subscription-tracker.ts
import { Observable } from '../observable/Observable';
import { Observer } from '../observer/Observer';
import { Subscription } from '../observable/Subscription';
// Store original methods
const originalSubscribe = Observable.prototype.subscribe;
const originalUnsubscribe = Subscription.prototype.unsubscribe;
let activeSubscriptions = 0;
// Wrapper for the subscribe method
function patchedSubscribe<T>(
this: Observable<T>,
observerOrNext?: Partial<Observer<T>> | ((value: T) => void),
error?: (error: any) => void,
complete?: () => void
): Subscription {
activeSubscriptions++;
// Reconstruct the observer to intercept complete/error
const observer =
typeof observerOrNext === 'function'
? { next: observerOrNext, error, complete }
: observerOrNext;
// Create a wrapper observer that decrements the counter on termination
const trackingObserver: Partial<Observer<T>> = {
next: (value) => {
observer?.next?.(value);
},
error: (err) => {
activeSubscriptions--;
observer?.error?.(err);
},
complete: () => {
activeSubscriptions--;
observer?.complete?.();
},
};
// Call the original subscribe with our tracking observer
// We pass `this` context explicitly using .call()
return originalSubscribe.call(this, trackingObserver);
}
// Wrapper for the unsubscribe method
function patchedUnsubscribe(this: Subscription): void {
// Only decrement if the subscription is not already closed
// This prevents double-counting if unsubscribe is called multiple times
if (!this.closed) {
activeSubscriptions--;
}
// Call the original unsubscribe
originalUnsubscribe.call(this);
}
// Our public API for the tracker
export const subscriptionTracker = {
start: () => {
activeSubscriptions = 0;
(Observable.prototype as any).subscribe = patchedSubscribe;
(Subscription.prototype as any).unsubscribe = patchedUnsubscribe;
},
stop: () => {
(Observable.prototype as any).subscribe = originalSubscribe;
(Subscription.prototype as any).unsubscribe = originalUnsubscribe;
},
getActiveSubscriptions: () => activeSubscriptions,
};
This utility gives us start() and stop() methods to enable/disable patching, and a getActiveSubscriptions() method to check the current count. It cleverly wraps subscribe to also watch for error and complete events, and refines the unsubscribe logic to check the closed property, just as the resources suggested.
5. Writing the Memory Leak Test
Now, let's use our new tracker. Create a new test file: src/memory.test.ts.
First, let's test a "good" operator that should not leak. Our take operator is a perfect candidate, as it completes the stream after a certain number of emissions.
// src/memory.test.ts
import { describe, it, expect, afterEach } from 'vitest';
import { subscriptionTracker } from './testing/subscription-tracker';
import { interval } from './observable/creators/interval';
import { take } from './operators/take';
import { Subscription } from './observable/Subscription';
describe('Memory Leak Detection', () => {
// Ensure we always stop tracking after each test
afterEach(() => {
subscriptionTracker.stop();
});
it('should not have memory leaks for a properly completing stream (e.g., with take)', (done) => {
subscriptionTracker.start();
expect(subscriptionTracker.getActiveSubscriptions()).toBe(0);
const stream$ = interval(10).pipe(take(3));
stream$.subscribe({
complete: () => {
// We need to wait a tick for the teardown logic to run
setTimeout(() => {
expect(subscriptionTracker.getActiveSubscriptions()).toBe(0);
done();
}, 0);
},
});
// Right after subscription, we expect 2 active subscriptions:
// 1. The outer subscription from our test.
// 2. The inner subscription created by `interval`.
expect(subscriptionTracker.getActiveSubscriptions()).toBe(2);
});
});
This test confirms that when a stream completes naturally, all its internal and external subscriptions are torn down, and our counter correctly returns to zero.
Now for the real test: detecting a leak. Let's create a "bad" version of an operator. Imagine a mergeMap-like operator that forgets to unsubscribe from its inner source.
Let's add a test case for a hypothetical leaky operator. For this exercise, we'll just simulate the leak by creating a dangling subscription.
// Add this to src/memory.test.ts
it('should detect a memory leak from an unterminated inner subscription', () => {
subscriptionTracker.start();
// A "leaky" operation: it creates a subscription that is never cleaned up
let leakySubscription: Subscription;
const mainSubscription = interval(100).subscribe(() => {
// On each tick, we create a new subscription but never store or unsubscribe it
leakySubscription = interval(20).subscribe();
// Simulate our test unsubscribing from the main stream after one emission
mainSubscription.unsubscribe();
});
// At this point, `mainSubscription` is unsubscribed, but `leakySubscription` is still active.
// Our tracker should detect this.
// Total subscriptions created:
// 1. For the outer `interval(100)`
// 2. For the inner `interval(20)`
// We unsubscribed from the outer one, but the inner one "leaked".
expect(subscriptionTracker.getActiveSubscriptions()).toBe(1);
// Clean up manually for the test to pass without Vitest complaining about open handles
leakySubscription!.unsubscribe();
expect(subscriptionTracker.getActiveSubscriptions()).toBe(0);
});
This test beautifully demonstrates the value of our tracker. Even though we unsubscribed from the main interval, the internally created subscription was abandoned and left running—a classic memory leak. Our tracker correctly reports 1 active subscription, failing the test and alerting us to the problem.
Test your understanding!
Consider the following code using a share operator, which you implemented earlier. The share operator is designed to keep its source subscription active as long as there is at least one downstream subscriber.
subscriptionTracker.start();
const shared$ = interval(100).pipe(share());
const sub1 = shared$.subscribe();
const sub2 = shared$.subscribe();
// What is the active subscription count here?
// Hint: `share` creates one subscription to the source, and then we have two from the test.
sub1.unsubscribe();
// What is the active subscription count here?
sub2.unsubscribe();
// And what is the count here, after all subscribers are gone?
Show answer
- After
sub2.subscribe(): The count should be 3.- One for
sub1. - One for
sub2. - One internal subscription that
sharemakes to the sourceinterval(100).
- One for
- After
sub1.unsubscribe(): The count should be 2.sub1is gone.sub2is still active.- The internal subscription is still active because
sub2is still listening.
- After
sub2.unsubscribe(): The count should be 0.- When the last subscriber (
sub2) unsubscribes,shareshould tear down its internal subscription to the source. All subscriptions are now properly closed. If the count were greater than 0, it would indicate a leak in yourshareoperator!
- When the last subscriber (
Conclusion
Congratulations on completing this module! You've tackled some very advanced topics, and this final lesson is arguably one of the most important for building a production-ready library.
Key Takeaways:
- Leaks Come from Lingering References: In reactive code, unterminated subscriptions are a primary cause of memory leaks.
- Manual Debugging is an Art: Using browser developer tools to compare heap snapshots is a powerful technique for hunting down memory issues.
- Automated Tracking Provides Safety: By monkey-patching
subscribeandunsubscribein a test environment, we can create a reliable safety net that automatically detects subscription leaks. - Teardown is Non-Negotiable: Every operator you create that involves an internal subscription must have robust teardown logic to clean up that subscription when the outer stream terminates.
You've built a solid foundation for your library, ensuring it is not only functionally correct and interoperable with RxJS, but also robust against memory leaks.
In our next and final module, Library Packaging & Documentation, we will prepare your project for the world. We'll configure package.json for publishing, generate API documentation, set up automated versioning, and finally, publish your library to the npm registry.