Skip to the solution
HardFrontend9 min read

How do you avoid memory leaks from subscriptions?

445 views

The problem

Question presented to candidate: "Users report the dashboard gets slower every time they navigate away and back, and the logs show the same handler firing several times per event. How would you find and fix subscription leaks in an Angular 22 app?"

What a strong answer should cover:

  • Why it leaks: a subscribe() to a long-lived source (a root service Subject, router events, interval, a websocket) keeps running after the component is destroyed; its callback keeps the component in memory and fires on every event.
  • What does not need cleanup: sources that complete by themselves, such as a single HttpClient call or take(1), release on completion.
  • Preferred fixes: let Angular own the subscription: the async pipe, toSignal(), or takeUntilDestroyed() (in an injection context, or takeUntilDestroyed(destroyRef) elsewhere). DestroyRef.onDestroy for anything else.
  • Older patterns still valid: a destroy$ Subject with takeUntil, or a Subscription bag unsubscribed in ngOnDestroy.
  • Operator placement: takeUntil must be the last operator, after switchMap and friends, or the inner subscription survives.
  • How to detect: count active subscriptions in a test after fixture.destroy(), heap snapshots for detached components, and lint rules.

Clarifying questions expected:

  • "Which sources does the dashboard subscribe to: HTTP only, or store and socket streams too?"
  • "Is it signal-based already, or subscribe() in ngOnInit?"

Code / implementation expected: Yes β€” several subscription styles in one component, with live subscriptions counted after it is destroyed.

  • rxjs
  • unsubscribe

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

9 min read Β· 10 sections

Read the code

AngularSeven ways to subscribe, counted after the component is destroyed
import { Component, DestroyRef, Injectable, inject, signal } from "@angular/core";
import { AsyncPipe } from "@angular/common";
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
import { BehaviorSubject, Observable, Subject, switchMap, takeUntil } from "rxjs";

// A root service whose stream never completes (like a WebSocket, a store, or router events).
@Injectable({ providedIn: "root" })
export class Ticker {
  active = signal<Record<string, number>>({});
  ticks(tag: string): Observable<number> {
    return new Observable<number>((sub) => {
      this.bump(tag, +1);
      let n = 0;
      const id = setInterval(() => sub.next(++n), 100);
      return () => { clearInterval(id); this.bump(tag, -1); }; // runs on unsubscribe
    });
  }
  private bump(tag: string, d: number) {
    this.active.update((a) => ({ ...a, [tag]: (a[tag] ?? 0) + d }));
  }
}

@Component({
  selector: "app-widget",
  imports: [AsyncPipe],
  template: `<p>widget alive: {{ viaAsync$ | async }} / {{ viaSignal() }}</p>`,
})
export class WidgetComponent {
  private ticker = inject(Ticker);
  private destroyRef = inject(DestroyRef);
  private destroy$ = new Subject<void>();

  viaAsync$ = this.ticker.ticks("async pipe");                                // template owns it
  viaSignal = toSignal(this.ticker.ticks("toSignal"), { initialValue: 0 });   // DestroyRef owns it

  constructor() {
    this.ticker.ticks("1 plain subscribe (LEAK)").subscribe();
    this.ticker.ticks("takeUntilDestroyed()").pipe(takeUntilDestroyed()).subscribe();
    this.ticker.ticks("takeUntil(destroy$)").pipe(takeUntil(this.destroy$)).subscribe();

    const outer = new BehaviorSubject("go");
    outer.pipe(takeUntil(this.destroy$), switchMap(() => this.ticker.ticks("2 takeUntil before switchMap (LEAK)"))).subscribe();
  }

  ngOnInit() {
    // Outside the constructor there is no injection context: pass the DestroyRef in.
    this.ticker.ticks("takeUntilDestroyed(ref) in ngOnInit").pipe(takeUntilDestroyed(this.destroyRef)).subscribe();
  }

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

@Component({
  selector: "app-root",
  imports: [WidgetComponent],
  template: `
    <button (click)="show.set(false)">Destroy widget</button>
    @if (show()) { <app-widget /> }
    <ul>
      @for (row of rows(); track row[0]) { <li>{{ row[0] }}: {{ row[1] }} active</li> }
    </ul>
  `,
})
export class AppComponent {
  show = signal(true);
  private ticker = inject(Ticker);
  rows = () => Object.entries(this.ticker.active()).sort();
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 105 of 121 in Angular

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Angular questions
Up nextHard

What is the difference between useClass, useValue, useFactory, and useExisting?

Continue