The Fourth Step into the World of RxJS: Unfinished Streams - Silent Killers of Applications

Throughout the first, second, and third articles, we have embarked on a fascinating journey together: from our initial introduction to Observables, where we grasped the fundamentals of the reactive approach, through mastering operators that enabled us to efficiently transform and filter data, to combining streams, which unlocked the ability to synchronize data from multiple sources. We gradually transformed RxJS from an intriguing tool for experimentation into a powerful instrument for real-world tasks. Now, having taken three confident steps, it is time to confront the dark side of reactive programming. Like any technology, RxJS has its pitfalls. One of the most insidious is unclosed subscriptions, which can lead to severe issues such as memory leaks, performance degradation, and even application crashes. The true power of RxJS tools demands not only technical knowledge from developers but also genuine professional expertise to build reliable and high-performance applications. If the first three steps were about creating and transforming streams, the fourth step is about taking responsibility for what has been created. This is a stride toward maturity in working with RxJS—understanding why subscription management is critical and how it impacts the quality of your applications. Join us to delve deeper into the world of RxJS, avoid common pitfalls, and become a developer who not only crafts engaging code but also ensures it is robust, optimized, and adaptable to any operational environment. Threats When Working with RxJS Modern Angular is hard to imagine today without RxJS. It empowers us to handle asynchronous tasks, manage state reactively, respond to events, and even construct complex data processing pipelines. With RxJS, you work with data streams (Observable). When you subscribe to an Observable, imagine it as attaching a hose to a water tap. The data stream starts flowing, events pour into your application in a continuous stream. The problem is that the tap won't close until you manually turn the handle. Subscriptions without unsubscribe are silent killers. While you test everything in short development sessions, the app works perfectly... until a QA engineer complains about smoke coming from their laptop after 30 minutes of interacting with your creation. Imagine you created a subscription inside an Angular component but forgot to "close" it when the component is no longer needed (e.g., the user navigates to another part of the app). What happens? Data continues to flow. Even if the UI no longer uses this data, the subscription remains active. This means that even when no one is watching, the stream keeps emitting events, straining both your system and the server. Memory stops being freed. RxJS streams act as long-lived objects. As long as the subscription is active, references to data persist in memory, and the garbage collector (GC) cannot release them. Hidden issues escalate. At first, this might seem unimportant — everything works. But after a few hours of app runtime (or several component navigations), problems start compounding: Performance degradation. Memory leaks. Browser crashes (especially on low-end devices). Why Won't You Notice the Problem Immediately? During development, it's easy to overlook RxJS subscription details. Everything looks fine: data updates correctly, no console errors, performance seems adequate in short sessions. But inefficiency always catches up. The first serious memory leak might strike days or weeks later when the app reaches users with low-end devices. Then you'll face an odyssey of hunting "phantom" bugs, blaming environment issues, and cursing "user error"... Memory Leak Example Imagine developing a real-time chat widget for a website. It receives live messages and displays them on the page. Everything seems perfect, but there's a hidden flaw. A tiny oversight... until the first performance degradation report lands directly from an end-user. import {ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit} from '@angular/core'; import {interval, map} from "rxjs"; import {NgForOf} from "@angular/common"; let instance = 1; @Component({ selector: 'app-chat', standalone: true, imports: [ NgForOf ], changeDetection: ChangeDetectionStrategy.OnPush, template: ` Real-time Chat {{ message }} `, styles: [` .chat-window { border: 1px solid #ccc; height: 300px; overflow-y: auto; } .message { padding: 8px; border-bottom: 1px solid #eee; } `] }) export class ChatComponent implements OnInit, OnDestroy { messages: string[] = []; instanceId = instance++; // Creation of a random string private getRandomString = () => (Math.random() + 1).toString(36).substring(2); constructor(private detector: ChangeDetectorRef) { } ngOnInit(): void { // Creation of a new data-stream every

May 17, 2025 - 15:24
 0
The Fourth Step into the World of RxJS: Unfinished Streams - Silent Killers of Applications

Throughout the first, second, and third articles, we have embarked on a fascinating journey together: from our initial introduction to Observables, where we grasped the fundamentals of the reactive approach, through mastering operators that enabled us to efficiently transform and filter data, to combining streams, which unlocked the ability to synchronize data from multiple sources. We gradually transformed RxJS from an intriguing tool for experimentation into a powerful instrument for real-world tasks.

Now, having taken three confident steps, it is time to confront the dark side of reactive programming. Like any technology, RxJS has its pitfalls. One of the most insidious is unclosed subscriptions, which can lead to severe issues such as memory leaks, performance degradation, and even application crashes. The true power of RxJS tools demands not only technical knowledge from developers but also genuine professional expertise to build reliable and high-performance applications.

If the first three steps were about creating and transforming streams, the fourth step is about taking responsibility for what has been created. This is a stride toward maturity in working with RxJS—understanding why subscription management is critical and how it impacts the quality of your applications.

Join us to delve deeper into the world of RxJS, avoid common pitfalls, and become a developer who not only crafts engaging code but also ensures it is robust, optimized, and adaptable to any operational environment.

Threats When Working with RxJS

Modern Angular is hard to imagine today without RxJS. It empowers us to handle asynchronous tasks, manage state reactively, respond to events, and even construct complex data processing pipelines.

With RxJS, you work with data streams (Observable). When you subscribe to an Observable, imagine it as attaching a hose to a water tap. The data stream starts flowing, events pour into your application in a continuous stream. The problem is that the tap won't close until you manually turn the handle.

Subscriptions without unsubscribe are silent killers. While you test everything in short development sessions, the app works perfectly... until a QA engineer complains about smoke coming from their laptop after 30 minutes of interacting with your creation.

Imagine you created a subscription inside an Angular component but forgot to "close" it when the component is no longer needed (e.g., the user navigates to another part of the app). What happens?

  1. Data continues to flow. Even if the UI no longer uses this data, the subscription remains active. This means that even when no one is watching, the stream keeps emitting events, straining both your system and the server.

  2. Memory stops being freed. RxJS streams act as long-lived objects. As long as the subscription is active, references to data persist in memory, and the garbage collector (GC) cannot release them.

  3. Hidden issues escalate. At first, this might seem unimportant — everything works. But after a few hours of app runtime (or several component navigations), problems start compounding:

    • Performance degradation.
    • Memory leaks.
    • Browser crashes (especially on low-end devices).

Why Won't You Notice the Problem Immediately?
During development, it's easy to overlook RxJS subscription details. Everything looks fine: data updates correctly, no console errors, performance seems adequate in short sessions. But inefficiency always catches up.
The first serious memory leak might strike days or weeks later when the app reaches users with low-end devices. Then you'll face an odyssey of hunting "phantom" bugs, blaming environment issues, and cursing "user error"...

Memory Leak Example

Imagine developing a real-time chat widget for a website. It receives live messages and displays them on the page. Everything seems perfect, but there's a hidden flaw. A tiny oversight... until the first performance degradation report lands directly from an end-user.

import {ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit} from '@angular/core';
import {interval, map} from "rxjs";
import {NgForOf} from "@angular/common";

let instance = 1;

@Component({
    selector: 'app-chat',
    standalone: true,
    imports: [
        NgForOf
    ],
    changeDetection: ChangeDetectionStrategy.OnPush,
    template: `
    

Real-time Chat

{{ message }}
`
, styles: [` .chat-window { border: 1px solid #ccc; height: 300px; overflow-y: auto; } .message { padding: 8px; border-bottom: 1px solid #eee; } `] }) export class ChatComponent implements OnInit, OnDestroy { messages: string[] = []; instanceId = instance++; // Creation of a random string private getRandomString = () => (Math.random() + 1).toString(36).substring(2); constructor(private detector: ChangeDetectorRef) { } ngOnInit(): void { // Creation of a new data-stream every 0.5 sec interval(500) .pipe( map((i) => `New message #${i + 1} - ${new Array(1000).fill(0) .map(() => this.getRandomString()).join('')}`) // Симуляция тяжелых данных ) .subscribe(message => { // Adding of the message to the collection this.messages.push(message); this.detector.detectChanges(); console.log(`The subscription ${this.instanceId} is finished.`); }); } ngOnDestroy(): void { console.log(`ChatComponent ${this.instanceId} destroyed`); } }

What's Wrong with This Component?

At first glance, nothing catastrophic: UI updates, everything works as intended.

But here's the catch: You did NOT unsubscribe from the stream!

When the user hides the chat, the ChatComponent gets destroyed. But the subscription firing twice per second continues to "live". It keeps generating messages to send... nowhere. As a result:

  • The stream remains active. Messages keep being generated in memory.
  • References to the messages[] array persist in memory.
  • The Garbage Collector (GC) cannot free allocated resources because the subscription maintains a "live" reference.

Let's See This in Action:

  1. Download the project from here and run it.
  2. Open browser DevTools and navigate to the Console tab.
  3. Click the chat open/close button multiple times.

You'll see an ever-increasing number of console messages. Worst of all, messages from already destroyed chat instances will keep appearing. In the Memory tab, you'll observe endless growth of application resource usage.

Why Is This Bad? Memory Leaks and Their Impact

When you subscribe to an Observable stream, it creates a reference to your subscriber code. An RxJS stream remains "alive" as long as there's at least one active subscription. This means that if you don't unsubscribe, the associated data will linger in memory indefinitely.

In our example, the subscription maintains a reference to the messages[] array. Even if the user closes the chat:

  • New message objects keep being generated and added to the array, though no longer needed.
  • Old array references stay "alive", and the Garbage Collector (GC) cannot reclaim them because the stream remains active.

The result? Memory gradually clogs up. In early stages, this goes unnoticed, but after 5-10 minutes of runtime, the app suddenly starts lagging.

The more active subscriptions remain, the more data your RxJS stream processes. Consequences:

  • CPU load spikes.
  • Low-end devices (e.g., older phones) struggle with excess computations.
  • At the worst possible moment, the browser crashes or severe GUI delays occur.

In our chat example, the bug is particularly dangerous because new messages are generated every 500ms. Multiply this by the number of abandoned subscriptions — and you get a disaster.

Why Isn't This Obvious?

Memory leak-related errors often develop gradually. Here are reasons why developers neglect unsubscribe early in development:

  • Initial performance seems fine. At first, you notice nothing — subscriptions work, UI updates.
  • Diagnostic complexity. Memory leaks only manifest long-term. You rarely "see" anomalies during short testing sessions.
  • Angular handles many tasks automatically. Framework tools often "mask" the issue until it accumulates.
  • Optimistic mindset. "It works — must be okay". But the truth is: unclosed subscriptions will cause issues at the worst possible time.

How to Fix It? Closing Subscriptions

Now that we understand leaving subscriptions open is bad, let's explore reliable solutions. Proper subscription management in Angular isn't as hard as it seems — make it a rule from day one.

1. Use Subscription for Manual Management

RxJS provides the Subscription class to manage subscriptions. By explicitly storing subscriptions, you can unsubscribe when the component is destroyed. Let's modify our problematic code using Subscription:

import {ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit} from '@angular/core';
import {interval, map, Subscription} from "rxjs";
import {NgForOf} from "@angular/common";

let instance = 1;

@Component({
    selector: 'app-chat',
    standalone: true,
    imports: [
        NgForOf
    ],
    changeDetection: ChangeDetectionStrategy.OnPush,
    template: `
    

Real-time Chat

{{ message }}
`
, styles: [` .chat-window { border: 1px solid #ccc; height: 300px; overflow-y: auto; } .message { padding: 8px; border-bottom: 1px solid #eee; } `] }) export class ChatComponent implements OnInit, OnDestroy { messages: string[] = []; instanceId = instance++; private subscriptions: Subscription[] = []; // Store all subscriptions here private getRandomString = () => (Math.random() + 1).toString(36).substring(2); constructor(private detector: ChangeDetectorRef) { } ngOnInit(): void { // Add observable to subscriptions list this.subscriptions.push(interval(500) .pipe( map((i) => `New message #${i + 1} - ${new Array(1000) .fill(0).map(() => this.getRandomString()).join('')}`) ) .subscribe(message => { // Add new message to the list this.messages.push(message); this.detector.detectChanges(); console.log(`Subscription ${this.instanceId} executed.`); })); } ngOnDestroy(): void { // Unsubscribe from all observables this.subscriptions.forEach(s => s.unsubscribe()); console.log(`ChatComponent ${this.instanceId} destroyed`); } }

What's Happening Here?

  • We create a Subscription object to store our message stream.
  • When the component is destroyed (ngOnDestroy), we call .unsubscribe(), terminating the data stream observation.

This is the simplest and most straightforward approach. By storing subscriptions separately, you retain full manual control.

You can also create a utility class to encapsulate this logic:

import {OnDestroy} from '@angular/core';
import {Subscription} from 'rxjs';

export abstract class DestructibleComponent implements OnDestroy {
    protected subs: Subscription[] = [];
    protected onDestroy?: () => void;

    ngOnDestroy(): void {
        this.subs.forEach(s => s.unsubscribe());
        if (this.onDestroy) this.onDestroy();
    }
}

Any component can inherit this class (extends) and add subscriptions to the subs array without redefining ngOnDestroy. For additional cleanup logic, the onDestroy method is available — it runs after unsubscriptions without interfering with them.

2. Use AsyncPipe: Minimal Code

If your data is only displayed in templates (e.g., via *ngFor or {{ }}), you can avoid manual subscriptions in TypeScript altogether. Angular's AsyncPipe handles unsubscriptions automatically.

Here's an example:

import {ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy} from '@angular/core';
import {interval, map, tap} from "rxjs";
import {AsyncPipe, NgForOf} from "@angular/common";

let instance = 1;

@Component({
    selector: 'app-chat',
    standalone: true,
    imports: [
        NgForOf,
        AsyncPipe
    ],
    changeDetection: ChangeDetectionStrategy.OnPush,
    template: `
    

Real-time Chat

{{ message }}
`
, styles: [` .chat-window { border: 1px solid #ccc; height: 300px; overflow-y: auto; } .message { padding: 8px; border-bottom: 1px solid #eee; } `] }) export class ChatComponent implements OnDestroy { instanceId = instance++; private getRandomString = () => (Math.random() + 1).toString(36).substring(2); messages$ = interval(500) .pipe( tap(() => console.log(`Subscription ${this.instanceId} is done.`)), map((i) => Array.from({length: i}).map((_, idx) => `New message #${idx + 1} - ${new Array(1000).fill(0) .map(() => this.getRandomString()).join('')}`)) ); constructor(private detector: ChangeDetectorRef) { } ngOnDestroy(): void { console.log(`ChatComponent ${this.instanceId} destroyed`); } }

Why is AsyncPipe Awesome?

  • Automatically subscribes to Observables when the template renders.
  • Unsubscribes automatically when the component is destroyed.
  • Keeps code extremely concise.

When to Use It?

Use AsyncPipe if the data stream is only consumed in the template and nowhere else in the code.

Best Practices for Subscription Management

1. Adopt a Unified Approach

Stick to one subscription management style across your project. For example:

  • Inherit DestructibleComponent for all components.
  • Use the widely-known takeUntil pattern.
  • Use manual Subscription management for niche cases.

2. Minimize Subscriptions

Combine streams using operators like merge, combineLatest, or switchMap instead of creating multiple small subscriptions.

3. Prefer AsyncPipe

Whenever possible, use AsyncPipe for template-bound data.

4. Monitor Performance in DevTools

Regularly audit app performance:

  • Use the Memory tab to detect leaks.
  • Profile with Performance to spot anomalies.

5. Log Subscription Cleanup During Development

Add console.log in ngOnDestroy to verify subscriptions close on component destruction.

Conclusion

Working with RxJS isn't just about mastering a new tool — it's a journey toward more efficient, organized, and profound modern app development. Along this path, mistakes like unclosed subscriptions are inevitable, but each is a step forward in your professional growth.

RxJS, like any complex yet powerful skill, demands patience, practice, and relentless determination to improve.

Keep experimenting, learning, and diving into details. Remind yourself that every hour spent mastering fundamentals pays off exponentially: in development speed, app stability, and the respect of colleagues who'll see you as a true professional.

The browser's memory, your users, and your team will thank you. Experiment boldly, make mistakes, fix them, learn, and push toward new horizons. Success in every aspect of development stems from your own effort to transform today's challenges into tomorrow's expertise.