Mastering RxJS Memory Leaks: The Leak Detective Handbook

Written by kzarman | Published 2023/07/20
Tech Story Tags: javascript | rxjs | memory-leak | rxjs-operators | typescript | software-development | angular | clean-code

TLDRMemory leaks happen when Observables linger around in memory, long after they’ve finished their tasks. They’re no longer needed, but they stick around anyway, taking up precious space. The more memory leaks you have, the less memory is available for the parts of your application that genuinely need it. In severe cases, this can even cause your application to crash altogether.via the TL;DR App

Friends, fellow JavaScript adventurers, lend me your ears (or eyes)! Today, we’ll take a joyride into the world of RxJS to tame the sneaky gremlin known as memory leaks. Fasten your seatbelts as we unveil the mystic arts of keeping our code clean and our spirits high.

The What: Memory Leaks, The Unseen Culprits

Alright, before we dive into our detective toolkit, it’s worth taking a moment to truly understand our adversary. Memory leaks. They might sound like something that sprouts in your garden after a heavy downpour, but trust me, they’re a lot less fun.

Think of your application’s memory as a high-end, all-you-can-eat buffet. Your Observables are the patrons, consuming memory instead of the latest chef’s special. Normally, they’d eat their fill (perform their operations) and then leave, making room for the next hungry Observable. All’s well in our digital eatery.

But here’s where memory leaks, the sneaky critters, slink in. Imagine a patron who eats, and then sits there. And sits. And sits some more. They’re not eating anymore, but they’re taking up valuable space, preventing new patrons from getting a seat. This squatter is our memory leak.

In the world of RxJS, memory leaks happen when Observables linger around in memory, long after they’ve finished their tasks. They’re no longer needed, but they stick around anyway, taking up precious space.

But why is this such a big deal? Well, the more memory leaks you have, the less memory is available for the parts of your application that genuinely need it. This can lead to slower processing times, and in severe cases, can even cause your application to crash altogether. Imagine your buffet getting so crowded with non-eating patrons that there’s no room left for new customers, or even for the staff to move. Chaos ensues, orders pile up, the kitchen gets overwhelmed, and eventually, the whole place could grind to a halt.

That’s the kind of digital disaster we’re looking to avoid. And armed with the right tools and know-how, it’s a disaster we’re going to sidestep with grace and style. So let’s roll up our sleeves and prepare to banish these sneaky squatters once and for all!

The How: Gearing Up Against Memory Leaks

Now that we’ve identified the culprit, it’s time to arm ourselves with some techy tools to prevent those notorious memory leaks in RxJS. We’ve got five tried-and-true techniques here, each ready to tackle memory leaks in its unique way. Ready? Let’s dive right in!

Async Pipe: The Auto-Handler

Angular users, rejoice! If you’re using Angular, you can call in the async pipe in your templates to handle subscribing and unsubscribing from Observables. Angular oversees the lifecycle of the async pipe, automatically unsubscribing when the component is destroyed. Here's a glance at how this works:

<div>{{ source$ | async }}</div>

In this snippet, Angular is taking the wheel, managing the Observable source$ via the async pipe and ensures the Observable isn’t left unattended, preventing any mischief. As you might have already noticed, using it requires one main condition to be met. The observable has to be rendered in the template, but this is not always the case. This brings us to the trusty ‘unsubscribe’.

The Trusty Unsubscribe

Unsubscribing from Observables is like finishing a good book: you close it, return it to the shelf, and then move on to the next one. And just like the end of a book, you’ve got to know when to say “that’s a wrap” to your Observables.

When you subscribe to an Observable, you get back a Subscription object. And this Subscription object carries an unsubscribe method. You'll typically want to call this method in the ngOnDestroy lifecycle hook if you're using Angular or componentWillUnmount in the case of React (unmounted for VueJS and etc.). Here's how you do it:

class MyComponent implements OnDestroy {

  ...

  private subscription: Subscription;

  constructor() {
    this.subscription = source$.subscribe();
  }

  ngOnDestroy() {
    this.subscription.unsubscribe();
  }
}

In this Angular snippet, we tell our Observable source$ to halt in its tracks when we're done with it by calling .unsubscribe(). It's like giving a gentle nudge, reminding it to let go. But what if you are managing a large number of subscriptions and don’t want to write repetitive code for storing subscriptions and unsubscribing?

The Multi-tasking Combination of takeUntil and Subject:

If you’re juggling multiple subscriptions within a component, takeUntil operator with a Subject has got your back. The concept here is to create a Subject that emits a value whenever subscriptions are no longer needed, for example when the component is destroyed or some other event has happened. Then, you use takeUntil(this.destroy$) on every subscription. This way, you're instructing all your Observables to wrap things up when the component is on its way out.

class MyComponent implements OnDestroy {
  private destroy$ = new Subject<void>();

  ...

  constructor() {
    observableOne$.pipe(takeUntil(this.destroy$)).subscribe();
    observableTwo$.pipe(takeUntil(this.destroy$)).subscribe();
    ...
    observableNinetyNine$.pipe(takeUntil(this.destroy$)).subscribe();
  }

  ngOnDestroy() {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

The takeUntil tells observableOne$observableTwo$ and others to keep on keeping on until destroy$ sends a signal. Once it does, the game is up. No more precious memory is being taken!

Important to remember that it doesn’t have to happen only when the component is destroyed

Here are two example cases:

class MyComponent {
  private stop$ = new Subject<void>();
  ...

  constructor() {
    sourceOne$.pipe(takeUntil(this.stop$)).subscribe();
    sourceTwo$.pipe(takeUntil(this.stop$)).subscribe();
    ...
    sourceNinetyNine$.pipe(takeUntil(this.stop$)).subscribe();
  }

  onStopStreaming() {
    this.stop$.next();
    this.stop$.complete();
  }

}

In the first one, stop$ is being triggered by onStopStreaming() event method.

class MyComponent {
  private stop$ = new Subject<void>();
  ...

  constructor() {
    sourceOne$.pipe(takeUntil(this.destroy$)).subscribe();
    sourceTwo$.pipe(takeUntil(this.destroy$)).subscribe();
    ...
    
    finalise$.pipe(
      tap(() => this.stop$.next()),
      tap(() => this.stop$.complete())
    ).subscribe().unsubscribe();
  }

}

And in the other one, it’s being triggered by another observable called finalise$. You can probably think of other use cases where this legendary combo can do the job greatly.

Auto-Complete Operators: The Effortless Unsubscribers

Alright, code aficionados, let’s talk about some real gems from the world of RxJS: the auto-complete operators. These operators are like the unsung heroes of your Observable sequences, providing an effortless way to handle subscriptions and ensure graceful unsubscription. Get ready to let these operators take the wheel while you sit back and relax! I’ll cover each operator briefly and provide links where you can read more so that you don’t get too bored and eventually reach the end of an article.

first(): The Early Bird

First up, we have the trustyfirst(). Just like the eager early bird, it swiftly grabs the first emitted value from the Observable sequence and then gracefully completes, bidding adieu to any lingering subscriptions. It's all about getting a head start!

elementAt(): The Precise Selection

Sometimes, you need to be precise, like when you’re searching for your favorite song in a playlist (still can’t come up with a better example). TheelementAt() operator comes to your rescue by allowing you to select a specific element from an Observable sequence based on its index. Once that desired element is emitted, the sequence is automatically completed. It's like having a direct line to the value you want, ensuring your subscribers get exactly what they're looking for.

take(): The Limited Edition

Next on the list is the versatiletake(). Think of it as a limited edition collector's item. With take(), you can specify the number of values you want to pluck from the Observable sequence. Once it reaches that magic number, it gracefully exits the stage, leaving the spotlight on the chosen few.

takeWhile(): The Conditional Watcher

Last but not least, we have the observanttakeWhile(). This operator acts as a watchful sentinel, keeping an eye on the values emitted by the Observable sequence. As long as a specific condition holds true, it dutifully passes the values along. But the moment the condition evaluates to false, it exits the stage with a graceful bow.

These auto-complete operators are like your trusty sidekicks, taking care of the subscription lifecycle and ensuring you don’t have to worry about memory leaks. So go ahead, embrace the simplicity, and let these operators do the heavy lifting for you!

Rock ’n’ Roll with Hot Observables

But what if you have multiple subscriptions to one Observable? Here comes our next super start.

Picture this: you’re at a concert and the band is jamming. Regardless of when you or other fans join in, the band doesn’t start the song over, right? The melody (data) keeps flowing! That’s the beauty of hot observables. They’re the cool rock stars of the RxJS world, belting out data, whether anyone is there to groove to it (subscribe) or not.

The charm of hot observables is their easy-going nature. Their data production doesn’t care for the formalities of subscription; it’s like they’re in their own world, performing data sequences independently. This means you’re not stuck initiating a new data sequence for each new fan (subscriber). Sounds like a pretty sweet gig for memory and processing power, eh?

Now, in the RxJS band, you’ve got some built-in functions like fromEvent()interval()of()timer(), etc., that are born hot observables. But guess what? You can also turn a cold observable into a hot one using the share() or other similar operators (shareReplay()connect()connectable(), etc). Check this out:

// cold observable, chilling alone
const cold$ = new Observable((observer) => {
  observer.next(Math.random());
});

// hot observable, ready to rock
const hot$ = cold$.pipe(share());

// Fans (subscribers) join the party
const subscription1 = hot$.subscribe(val => console.log(`Fan 1: ${val}`));
const subscription2 = hot$.subscribe(val => console.log(`Fan 2: ${val}`));

setTimeout(() => {
  // A late fan gets the same experience
  const subscription3 = hot$.subscribe(val => console.log(`Fashionably late fan 3: ${val}`));
}, 1000);

See what we did there?

We transformed a lone, cold observable into a hot one with the share() operator. Now, no matter when the fans (subscribers) decide to join the party, they're jamming to the same data, reducing the need for multiple encores (executions).

There you go, code rockstars! You’ve got the power of hot observables at your fingertips. Ready to rock ’n’ roll with memory efficiency? Let’s hit it!

Let’s view a bit more boring example that is very common in daily life:

getUserData() {...} // makes api request and returns user data as Observable
getUserAccounts() {...} // makes api request and returns user accounts as Observable
getUserTransactions() {...} // makes api request and returns user transactions as Observable
const user$ = getUserData();

// option 1: using cold observable 
const coldUser$ = user$; // API will be called 2 times
const userAccounts$ = coldUser$.pipe(concatMap((user)=>getUserAccounts(user)));
const userTransactions$ = coldUser$.pipe(concatMap((user)=>getUserTransactions(user)));

// option 2: using hot observable case
const hotUser$ = user$.pipe(share()); // API will be called once and result will be shared
const userAccounts$ = hotUser$.pipe(concatMap((user)=>getUserAccounts(user)));
const userTransactions$ = hotUser$.pipe(concatMap((user)=>getUserTransactions(user)));

<div>{{ userAccounts$ | async }}</div>
<div>{{ userTransactions$ | async }}</div>

Both userAccounts$ and userTransactions$ utilize a response from a common observable. The advantage of using the hotUser$ observable (as in option 2) is that the API for fetching user data will be called only once since there will be a single subscription, and the response will be shared.

The Wrap: You’re Now a ‘Leak Detective’

And that’s a wrap, folks! Armed with async,unsubscribetakeUntil, and many others, you're now a certified RxJS memory leak detective. You're ready to charge into the battlefield, slaying memory leaks left and right.

Just remember, the secret to keeping your code clean is understanding and vigilance. With these trusty tools and strategies, you’re on your way to crafting efficient, leak-free code. Good luck, fellow code warriors. May your code be bug-free and your coffee strong!

Also published here.

Lead image by Lokajit Tikayatray


Written by kzarman | Senior software engineer passioned about algorithms, data structures, and tech frameworks and tools
Published by HackerNoon on 2023/07/20