DEV Community

Peter Saktor
Peter Saktor

Posted on

Avoid Memory Leaks in Angular When Using takeUntil with Higher-Order RxJS Operators

When working with Angular and RxJS, takeUntil is a common operator for unsubscribing from observables, especially in component lifecycle management. However, if you're using takeUntil incorrectly with higher-order mapping operators like mergeMap, switchMap, concatMap, exhaustMap, you may still end up with memory leaks.

The Problem

A common mistake is placing takeUntil before a higher-order operator in your observable pipeline. This seems logical, but it doesn't always behave as expected. When takeUntil is upstream of a higher-order operator, it won't effectively unsubscribe the inner subscription created by that operator.

❌ Problematic Pattern

this.counterB$.pipe(
  takeUntil(this.destroySubject),
  mergeMap((value) => this.counterA$)
).subscribe(...);
Enter fullscreen mode Exit fullscreen mode

In the example above, the outer observable (counterB$) is unsubscribed, but the inner observable (counterA$ from mergeMap) continues to emit values even after the component is destroyed. That’s your memory leak!

Why It Happens

Higher-order operators return new inner observables. If takeUntil is applied before them, it only stops the outer observable. But the inner one lives on.

The Correct Pattern

To ensure both outer and inner subscriptions are cleaned up properly, place takeUntil after the higher-order operator.

βœ… Recommended Pattern

this.counterA$.pipe(
  mergeMap((value) => this.counterB$),
  takeUntil(this.destroySubject),
).subscribe(...);
Enter fullscreen mode Exit fullscreen mode

In this corrected example, the whole observable chain, including inner subscriptions, is properly unsubscribed when the destroySubject emits.

Real-World Example

Service Providing Observables

@Injectable({ providedIn: 'root' })
export class TakeUntilLeakChildService {
  counterA$ = interval(2000);
  counterB$ = interval(1000);
}
Enter fullscreen mode Exit fullscreen mode

Component Subscription

@Component({
  selector: 'app-take-until-leak-child',
  imports: [],
  templateUrl: './take-until-leak-child.component.html',
  styleUrl: './take-until-leak-child.component.scss'
})
export class TakeUntilLeakChildComponent implements OnInit, OnDestroy {

  counterService = inject(TakeUntilLeakChildService);

  counterA$ = this.counterService.counterA$;
  counterB$ = this.counterService.counterB$;

  destroySubject = new Subject();

  ngOnInit(): void {
    this.counterA$.pipe(
      mergeMap((value) => {
        return this.counterB$;
      }),
      takeUntil(this.destroySubject),
    ).subscribe((value) => {
      console.log('Counter A:', value);
    });

    this.counterB$.pipe(
      takeUntil(this.destroySubject),
      mergeMap((value) => {
        return this.counterA$;
      }),
    ).subscribe((value) => {
      console.log('Counter B:', value);
    });
  }

  ngOnDestroy(): void {
    console.log('Child component destroyed!');
    this.destroySubject.next(true);
    this.destroySubject.complete();
  }
}
Enter fullscreen mode Exit fullscreen mode

Console Observation

When inspecting the console, you may find that:

  • counterA$ is unsubscribed correctly.
  • counterB$ continues to emit values even after component destruction - memory leak.

console output

Related Note on takeUntilDestroyed

The same principle applies when using Angular's takeUntilDestroyed operator. If you're using takeUntilDestroyed with higher-order mapping operators, make sure it is placed after those operators to properly unsubscribe inner observables and avoid memory leaks.

Summary

To prevent memory leaks:

  • Always place takeUntil (or takeUntilDestroyed) after higher-order operators.
  • Understand that mergeMap, switchMap, concatMap, and exhaustMap create inner observables that need cleanup.

Happy coding!

Top comments (0)