RxJS Mastery – Memory Leaks

RxJS memory leaks

So often you hear or read that unsubscribing is important when using RxJS. Otherwise those infamous “memory leaks” are happening. But I’m wondering whether most web developers understand what a memory leak is. And if so, how to detect it in the browser.

In this article my goal is to show that memory leaks can slow an application down or eventually lead to its crash.

Causing a memory leak

Memory leaks happen when allocated memory that is no longer needed, isn’t released. When using RxJS it can happen easily that memory becomes unreachable. To understand that we are going to show a little example in Angular. It will be based on my e-commerce playground project. But don’t worry, the relevant code is shown in this article.

The below product detail component is loading product information from a store. This information is offered as Observable and for the sake of demonstration we are subscribing to it in the component itself:

export class ProductDetailComponent implements OnInit {

  products: Product | null = null;

  constructor(private store: Store,
              private route: ActivatedRoute) {
  }

  ngOnInit(): void {
    const id = this.route.snapshot.paramMap.get('id');
    if (!!id) {
      this.store.select(productDetails(+id))
        .pipe(filter(Boolean))
        .subscribe({
          next: product => this.product = product
        });
    }
  }
}

The ngOnInit method is called during the initialization of the component. It reads the ID of the URL, e.g. of /product/:id, and loads the product having that ID from the store. Hence, every time the component is initialized a new subscription happens.

You might already see the memory leak. Anyway, in the next section, we are going into more detail.

Detecting a memory leak

For demo purposes, the above store select block is wrapped with a for loop and we push the product(s) to an array.

// types adapted
products: (Product | null)[] = [];

// subscribing many times
for (let i = 0; i <= 1000; i++) {
  this.store.select(productDetails(+id))
    .pipe(filter(Boolean))
    .subscribe({
    next: product => this.products.push(product)
  });
}

This leads to 1000 subscriptions each time the component is initialized. Let’s see how our browser, here Google Chrome, reacts to that.

The situation before opening the detail view

Below we have opened the overview page with a list of products. We can inspect the current memory situation by creating a heap snapshot in the dev tool’s Memory tab:

Currently, the size of the snapshot is 15.6 MB and you can see that we have 36 normal subscriptions. If we switch from “Summary” to “Statistics” we can see the graph below:

15’596 kB of total heap memory.

Visiting the detail view once

Remember that 1000 subscriptions are done each time we visit the detail view for products. This leads to the following situation after the first click on one of the products (here Bag):

1030 subscriptions are shown in the second heap snapshot. While the size increased from 15.6 to 18.0 MB. Also on the statistics side, an increase can be seen:

Visiting the detail view seven times

Let’s imagine you work with an application using RxJS for some minutes, maybe hours without reloading the page once. Because this is what we want on Single Page Applications. Smooth interactions and no hard reloads. If RxJS is not properly used and subscriptions are not managed, you will run into problems.

The below screenshot shows the situation after visiting seven product detail views. But you can imagine what happens after longer usage with even larger data.

Here the snapshot size increased to 30.7 MB.

Unsubscribing to prevent memory leak

Let us now unsubscribe to prevent the memory leak. This can be done in various ways, but the most elegant one in Angular in our case, is the async pipe. This pipe unsubscribes automatically when required. Our code is changed to:

// component field
product$: Observable<Product | null> | null = null;

// assignment
this.product$ = this.store.select(productDetails(+id))
  .pipe(filter(Boolean))

The corresponding template subscribes to the Observable with the before-mentioned async pipe:

<div *ngIf="product$ | async as product">

  <h1>{{ product.name }}</h1>

  <p>
    <strong>Price {{ product.price }}$</strong>
  </p>
  <p>
    {{ product.description }}
  </p>
</div>

When we revisit the situation, we still find a lot of subscriptions on the detail view.

RxJS no memory leak after unsubscribing

But those are cleaned up when navigating away. So, even if we revisit many times the detail view, the situation looks quite stable:

The snapshot size as well as the number of subscriptions look healthy.

RxJS finalize to check unsubscribing

To show that the unsubscribing really happens, we can add the finalize operator into our pipe:

this.product$ = this.store.select(productDetails(+id))
  .pipe(
    filter(Boolean),
    finalize(() => console.log('finalized'))
  )

This logs “finalize” into the console when we leave the detail view:

Conclusion

Above you have seen a memory leak caused by bad RxJS usage. There are many variants of it and you have to pay attention. Luckily most of the memory leaks can be spotted in the code, just when unsubscribe is missing. Additionally, it makes sense to analyze the running application, especially if slow performance is reported.

This post is part of the RxJS mastery series.