siliceum

Angular and RxJS: avoiding memory leaks and mastering reactivity

When I discovered RxJS a few years ago with Angular, I just saw .subscribe() everywhere on things called observables.

But what I didn’t see yet were memory leaks, weird behaviors, and wobbly operator chains.

Over time, I understood that reactive programming is much more than a tool for managing API calls. It’s a paradigm shift, which also implies good practices and a different mental model.

But first, what is an Observable?

An observable is an asynchronous data stream that can be listened to over time.

It’s at the heart of reactive programming with RxJS.

The basic idea: a source that emits values

Imagine a pipe through which data arrives over time:

const obs$ = new Observable(observer => {
  observer.next(1);
  observer.next(2);
  observer.next(3);
  observer.complete();
});

Here, we create a stream that emits 1, 2, 3, then stops. To “listen” to this stream, we use subscribe():

obs$.subscribe(value => console.log(value));
// outputs: 1, 2, 3

Difference with a Promise

FeaturePromiseObservable
ValuesSingleMultiple over time
CancelableNoYes
Lazy (on demand)YesYes
Operators.then(), .catch().pipe(), map(), filter()
ExecutionUpon creationAt subscribe() time
searchInput$.pipe(
  debounceTime(300),
  switchMap(query => this.api.search(query))
).subscribe(results => {
  this.results = results;
});
  • searchInput$ is an observable of keyboard events: we get changes from the “Search for a user” field
  • debounceTime(300): waits for a 300ms pause: we want to avoid launching a search every time a user presses a key. The debounce here waits 300ms after the last emitted value to continue the stream
  • switchMap(...): we switch observables in the stream: this.api.search(query) will instantiate a new observable (an http request) that we really want to wait for to get its result. So we switch streams
  • subscribe(...): receives results and updates the UI (actually, if you’re doing Angular, I prefer the | async pipe)

An observable can:

  • emit 0, 1 or multiple values
  • be infinite (for example: fromEvent, interval)
  • be cold or hot (see next section)
  • be combined, transformed, filtered, delayed, retried, etc.

RxJS provides over 60 operators to compose streams in an expressive and readable way.

Cold vs Hot Observable: understanding the difference

Cold Observable

An observable is said to be “cold” when the source is re-evaluated for each subscription. This means each subscriber has its own lifecycle.

const cold$ = new Observable(observer => {
  console.log('New subscriber');
  observer.next(Math.random());
});

cold$.subscribe(val => console.log('Subscriber 1:', val));
cold$.subscribe(val => console.log('Subscriber 2:', val));
New subscriber
Subscriber 1: 0.42
New subscriber
Subscriber 2: 0.88
Typically: http.get(), of(), from()

Hot Observable

An observable is said to be “hot” when it shares its data source between subscribers.

const subject = new Subject();

subject.subscribe(val => console.log('Subscriber 1:', val));
subject.next(1);
subject.next(2);

subject.subscribe(val => console.log('Subscriber 2:', val));
subject.next(3);
Subscriber 1: 1
Subscriber 1: 2
Subscriber 1: 3
Subscriber 2: 3
Typically: Subject, fromEvent, WebSocket, etc.

Cold + sharing = hot (with shareReplay)

It’s possible to make a cold observable “hot” by sharing it with share() or shareReplay():

const api$ = this.http.get('/data').pipe(shareReplay(1));

This avoids triggering multiple HTTP requests if multiple subscribers subscribe.

Best practices to adopt

With great power comes great responsibility, as the uncle would say.

1. Always handle unsubscription

A subscribe() without unsubscribe() = guaranteed memory leak. By default, subscriptions to observables don’t trigger automatic unsubscription (like JS events actually).

Here is what to do:

  • Use takeUntil() or take(1): the operator completes the observable automatically

    • takeUntil(notifier$)

    It works by listening to another observable notifier$ which, when it emits, causes completion (and thus unsubscription) of the source observable.

    It doesn’t trigger unsubscribe until this notifier emits, but once done, unsubscription is automatic.

    • take(1) automatically completes the observable after the first emission, which triggers an automatic unsubscribe for this subscription.
  • Unsubscribe unsubscribe() in your component’s ngOnDestroy

  • Use async pipe in Angular templates: The async pipe handles everything: it subscribes AND unsubscribes automatically.

<div *ngIf="data$ | async as data">
  {{ data.title }}
</div>

2. Avoid nested subscriptions

Don’t do this:

this.a$.subscribe(a => {
  this.b$.subscribe(b => {
    // ...
  });
});

Use switchMap, mergeMap, concatMap, depending on the desired behavior:

this.result$ = this.a$.pipe(
  switchMap(a => this.b$)
);

Why?

  • Because you need three boxes of Aspirin to read a pyramid of doom (you know, when you have a bunch of nested subscribe()
  • Error handling becomes complicated: each subscribe() manages its own errors. Nesting = guaranteed acrobatics to propagate or centralize errors correctly
  • Guaranteed memory leaks
  • Uncontrolled execution order

3. Choose the right operator

There’s a whole bunch of them, so don’t hesitate to pick the most relevant one! Some I use the most:

  • switchMap(): cancels the previous observable on each new emission

    Useful for real-time user searches.

  • debounceTime(500): waits for a calm period before emitting

    Useful for reducing noise on search fields.

  • map(val => val * 2): transforms values

    Simple data manipulation.

  • throttleTime(2000): ignores emissions too close together

    Anti-spam on submit button.

  • shareReplay(1): shares and caches the latest values

    To avoid calling an API 5 times for 5 subscribers.

To go further: learnrxjs.io

The most common RxJS mistakes

1. Forgetting to unsubscribe

This is the most frequent trap. A forgotten subscribe() in an Angular component means a subscription that survives the component’s destruction, still listening to events in the background.

// DON'T DO THIS
ngOnInit() {
  this.dataService.getData().subscribe(data => {
    this.data = data; // this callback still runs after ngOnDestroy
  });
}

2. Subscribing inside a subscribe

Already mentioned above, but it’s so common it’s worth repeating. Nested subscribe() calls are the main source of unreadable code and memory leaks in Angular.

3. Confusing switchMap and mergeMap

Using mergeMap where switchMap is expected (typically a search) means previous requests are not canceled, and results arrive out of order.

4. Not handling errors in the pipe

A missing catchError in a pipe can silently kill the entire stream. Always provide a fallback:

this.data$ = this.http.get('/api/data').pipe(
  catchError(err => {
    console.error('API error:', err);
    return of([]); // fallback: empty array
  })
);

5. Overusing shareReplay without limits

shareReplay() without parameters keeps everything in memory. Prefer shareReplay(1) to keep only the latest value, and shareReplay({ bufferSize: 1, refCount: true }) to release the source subscription when no one is listening.

Flattening operator choice guide

OperatorBehaviorTypical use case
switchMapCancels the previous observable on each new emissionReal-time search, autocomplete
mergeMapRuns all observables in parallelLogging, independent actions
concatMapRuns observables in order, one at a timeRequest queue, sequential operations
exhaustMapIgnores new emissions until the previous completesForm submission (anti double-click)

When in doubt, start with switchMap. It’s the safest in most cases (search, navigation, filters). Only use mergeMap if you truly need all requests to run in parallel.

Testing observables: marble testing

RxJS provides a testing mechanism called marble testing that lets you describe streams visually:

import { TestScheduler } from 'rxjs/testing';

const scheduler = new TestScheduler((actual, expected) => {
  expect(actual).toEqual(expected);
});

scheduler.run(({ cold, expectObservable }) => {
  const source$ = cold('  -a-b-c|');
  const expected = '      -A-B-C|';

  const result$ = source$.pipe(map(v => v.toUpperCase()));
  expectObservable(result$).toBe(expected);
});

Each character represents a time unit (frame): - is an empty tick, a letter is an emission, | is completion, # is an error. It’s a powerful tool for testing complex streams (debounce, switchMap, retry…) in a deterministic way.

Think long-term

RxJS is an extremely powerful tool, but without good practices, it can quickly become hard to manage.

Before diving into code, take time to think about:

  • The “temperature” of your observables: clearly distinguish “cold” observables (which produce data on each subscription) from “hot” ones (which emit independently of subscribers).
  • Who subscribes, when and why: understand the lifecycle of your subscriptions to avoid memory leaks.
  • How to handle cancellation: always plan how and when your subscriptions should be cleaned up (unsubscribed).
  • The robustness of your streams: imagine your operator chains capable of handling errors, interruptions, and even evolving without breaking the application (for example with logical rollbacks).

Finally, test your RxJS streams independently from components to guarantee their reliability and ease maintenance.

In summary

RxJS is not just a tool for “making API calls” or “listening to events”.

It’s a structured and declarative way of thinking about asynchronous data stream management.

And like any powerful tool misused… it can become a nightmare.

And you, what are your favorite RxJS operators?

Photo de Mickaël  JACQUOT

Written by

Mickaël JACQUOT

FullStack Developer