What this usually means
Angular applications use RxJS heavily for asynchronous operations: HTTP calls, router events, form value changes, and custom services. Each subscription creates a reference from the observable to the subscriber. If the component is destroyed (navigated away, *ngIf removed) but the subscription remains active, the observable holds a reference to the subscriber, preventing garbage collection of the entire component and its DOM. This is the classic 'forgotten subscription' leak. The root cause is almost always a missing unsubscribe call in ngOnDestroy, or using a manual subscribe() instead of the async pipe. Angular's own Router events and ActivatedRoute params are common culprits because they are long-lived singletons.
The first ten minutes — establish facts before touching code.
- 1Open Chrome DevTools > Performance > Record a short session where you navigate into and out of a suspect component. Check if memory usage doesn't drop after navigating away.
- 2Take a heap snapshot before and after navigating to the component. Compare retained size of detached elements and Subscription objects.
- 3In DevTools, search for 'Subscription' in the heap snapshot. Look for subscriptions that reference your component's class name.
- 4Run `ng serve` with `--source-map` and use the Angular DevTools profiler to identify components with high change detection frequency.
- 5Add a console log in ngOnDestroy to confirm the component is actually destroyed when expected.
- 6Use the `takeUntil` pattern with a Subject that completes in ngOnDestroy, and verify the Subject completes using RxJS `finalize` operator.
The specific files, logs, configs, and dashboards that usually own this bug.
- searchComponents/services with manual `.subscribe()` calls in ngOnInit or constructor (especially those not using async pipe)
- searchRouter.events subscription (e.g., `this.router.events.subscribe(...)`) — it never completes automatically
- searchActivatedRoute.params, queryParams, fragment — Angular's Router creates long-lived observables that must be unsubscribed
- searchFormControl.valueChanges subscriptions in components that are reused or destroyed
- searchCustom event emitters (EventEmitter is a Subject) in services that are not unsubscribed in the consuming component
- searchThird-party library subscriptions (e.g., for WebSocket, IndexedDB, or ResizeObserver) that bypass Angular's zone
- searchAngular's `@Output()` event emitters if parent components hold references to child components via subscriptions
Practical causes, not theory. These are the things you will actually find.
- warningUsing `.subscribe()` directly in a component without storing the Subscription and calling `.unsubscribe()` in ngOnDestroy
- warningForgetting to complete an observable that is long-lived (e.g., from a service that uses Subject or BehaviorSubject)
- warningUsing `takeUntil` but not emitting a value or error from the notifier Subject, or not calling `.next()`/`.complete()` in ngOnDestroy
- warningNested subscriptions where an inner subscription depends on an outer observable that never completes
- warningSubscribing to Router events in a service that is provided in root — the service lives forever, but the subscription accumulates
- warningUsing the async pipe incorrectly: e.g., subscribing to an observable that emits multiple values but the async pipe is inside an *ngIf that keeps recreating the subscription
- warningThird-party libraries that create their own observables and subscribe internally, but you don't have access to unsubscribe
Concrete fix directions. Pick the one that matches your root cause.
- buildReplace all manual `.subscribe()` in templates with the `async` pipe — it handles unsubscribe on component destroy automatically
- buildFor components that must subscribe imperatively, use the `takeUntil` pattern: create a private Subject `ngUnsubscribe$`, pipe `takeUntil(this.ngUnsubscribe$)` on every observable, and call `this.ngUnsubscribe$.next(); this.ngUnsubscribe$.complete();` in ngOnDestroy
- buildUse the `untilDestroyed` operator from `ngx-take-until-destroy` library as a declarative alternative (but ensure it's properly configured)
- buildFor long-lived services, avoid subscribing in the service itself; instead return the observable and let the component handle subscription with async pipe
- buildUse `first()` or `take(1)` for observables that only emit once (e.g., HTTP requests, route params that don't change)
- buildIn Angular 16+, leverage the new `@let` syntax with `async` pipe to avoid multiple subscriptions
- buildAudit all subscriptions in components that are frequently created/destroyed, like modals, dropdowns, and routed views
A fix you cannot prove is a guess. Close the loop.
- verifiedTake a heap snapshot after navigating through the app multiple times — check that the number of Subscription objects stays constant, not growing
- verifiedUse Chrome DevTools' 'Allocation instrumentation on timeline' to record allocations while interacting; look for Subscription allocations that are never freed
- verifiedMonitor memory usage in the Performance tab over a 5-minute session of repeated navigation; memory should stabilize, not climb linearly
- verifiedAdd a custom dev-only service that logs the count of active subscriptions (by monkey-patching Observable.prototype.subscribe) and assert it returns to zero after component destroy
- verifiedIn unit tests, use `fakeAsync` and flush microtasks, then assert that the component's subscriptions are completed (e.g., spy on ngOnDestroy and verify unsubscribe called)
- verifiedUse Angular DevTools' 'Profiler' to check change detection cycles — if they increase without user interaction, subscriptions might be holding old component instances
Things that make this bug worse or harder to find.
- warningSubscribing to Router events inside a component without unsubscribing — Router is a singleton, the observable never completes
- warningUsing `takeUntil` but forgetting to actually call `.next()` in ngOnDestroy — the notifier Subject must emit for the pattern to work
- warningUsing `async` pipe on an observable that emits the same reference (like a BehaviorSubject) — it still works, but don't assume it prevents all leaks
- warningAssuming that `ngOnDestroy` is always called — there are edge cases (e.g., app killed by browser, NavigationCancel) where it may not fire, so use `takeUntil` with a finalize as belt-and-suspenders
- warningSubscribing inside a service that is provided in a module — the service lives as long as the module, subscriptions accumulate
- warningNot using `shareReplay` or `publishReplay` correctly — they can cause memory leaks if the source never completes and references are held
- warningUsing `async` pipe with an observable that has side effects (like HTTP calls) — the async pipe resubscribes on each change detection, causing multiple requests
The 50MB Heap Dump That Took Down a Dashboard
Timeline
- 09:15Alert: Customer dashboard page becomes unresponsive after 10 minutes of use
- 09:18Reproduced locally: memory climbs from 30MB to 120MB in 5 minutes of clicking through tabs
- 09:25Took heap snapshot: 1,200 detached `DashboardComponent` instances, each retained by Subscription
- 09:35Searched for 'Subscription' in heap: found 500+ subscriptions referencing `router.events`
- 09:40Identified a shared service `NavigationService` that subscribes to Router events in its constructor
- 09:45Realized the service never unsubscribes, and it's provided in a lazy-loaded module — each navigation creates a new instance
- 09:50Fixed: moved subscription to component with `takeUntil`, or used `router.events.pipe(take(1))`
- 10:00Verified: heap snapshot after fix shows only 10 detached components, memory stable after 10 minutes
I got the alert at 9:15 AM on a Tuesday. The customer dashboard — a complex Angular SPA with real-time data widgets — was freezing after about 10 minutes of use. Users reported that clicking between tabs would get slower and slower until the browser tab crashed. Our monitoring showed memory growing linearly. I reproduced it locally in two minutes: open the app, click through three tabs repeatedly, watch the memory climb from 30MB to over 120MB in five minutes. That's a textbook memory leak.
I took a heap snapshot in Chrome DevTools and filtered for 'DashboardComponent'. To my horror, there were over 1,200 detached instances, each held alive by a Subscription. The common reference was `router.events`. I traced it to a shared service called `NavigationService` that we used to centralize route change logic. In its constructor, it subscribed to `this.router.events.subscribe(...)`. That service was provided in a lazy-loaded module, so every time the user navigated to that module, a new instance of the service was created — but the old subscription from the previous instance was never cleaned up. Each subscription held a reference to the old service instance, which held references to the whole component tree.
The fix was straightforward: I moved the subscription out of the service constructor and into the component that needed it, using the `takeUntil` pattern. For the service itself, I changed it to return an observable that components could consume with the async pipe. After deploying, I verified with another heap snapshot: only 10 detached components (expected garbage collection lag) and memory remained flat. The lesson: never subscribe to long-lived observables (like Router events) inside a service that can be instantiated multiple times. Always let the component manage subscriptions, or use the async pipe.
Root cause
A shared service provided in a lazy-loaded module subscribed to `router.events` in its constructor without unsubscribing. Each navigation to that module created a new service instance, but the old subscription prevented garbage collection of the previous instance and its associated components.
The fix
Moved the subscription to the component using `takeUntil(this.destroy$)` and `destroy$.next()` in ngOnDestroy. For the service, removed the subscription and instead exposed an observable (e.g., `navigation$ = this.router.events.pipe(...)` ) for components to consume with async pipe.
The lesson
Never subscribe to long-lived observables inside a service that can have multiple instances. Either use the async pipe in components or ensure subscriptions are cleaned up in ngOnDestroy. Always check heap snapshots for detached components — they are the smoking gun for subscription leaks.
Every Angular component has a lifecycle: ngOnInit, ngOnDestroy. When you subscribe to an observable inside the component (e.g., in ngOnInit), you create a reference from the observable to the component's callback. If the component is destroyed but the subscription remains active, the observable keeps a reference to the component, preventing garbage collection. This is the core mechanism of the leak.
The async pipe is the safest way to subscribe because it automatically unsubscribes when the component is destroyed. However, there are cases where you cannot use async pipe — for example, when you need to perform side effects on emission (like showing a toast). In those cases, you must manage subscriptions manually. The standard pattern is to create a private Subject `private destroy$ = new Subject<void>()`, pipe `takeUntil(this.destroy$)` on every observable, and call `this.destroy$.next(); this.destroy$.complete();` in ngOnDestroy. This ensures all subscriptions are torn down when the component is destroyed.
The most effective technique is to take heap snapshots before and after a suspected leak. Open DevTools > Memory > Heap snapshot. Take a snapshot, perform the action that may cause a leak (e.g., navigate to a component and back), then take another snapshot. In the 'Comparison' view, filter for 'detached' — these are objects that are no longer in the DOM but still referenced. Look for your component class names. If they appear, examine the retaining path. Common retaining paths include 'Subscription' -> 'Subscriber' -> 'Component'. You can also search for 'Subscription' directly and count them.
Another tool is the 'Allocation instrumentation on timeline'. Record a short session, then stop and look for allocations of 'Subscription'. If you see many subscriptions being allocated but not freed (i.e., they remain in the 'retained' set), you have a leak. You can also use the 'Performance' tab to record memory usage over time — a steady increase is a red flag.
The `takeUntil` operator is the most common fix, but it has a critical pitfall: the notifier Subject must emit a value (or complete) for `takeUntil` to unsubscribe. If you forget to call `.next()` in ngOnDestroy, the subscription lives forever. Always call both `next()` and `complete()` to be safe. Also, be aware that if the notifier Subject errors, `takeUntil` will not fire (though Subjects rarely error). A robust pattern is to use `finalize` as a side-effect to log completion.
Another pitfall: if you have multiple subscriptions, you might be tempted to use a single `takeUntil` for all, but ensure that each observable is piped with `takeUntil(this.destroy$)`. If one observable completes before the component is destroyed, it's fine; but if the notifier Subject completes early (e.g., via an error), all other subscriptions using that same notifier will also complete prematurely. So consider using a separate notifier per logical group, or use the `takeUntilDestroyed` operator from Angular 16+ (which is automatically tied to the component's destroy lifecycle).
Some observables never complete naturally: Router events, ActivatedRoute params, FormControl valueChanges, and custom Subjects in services. These are the most common sources of leaks. For Router events, prefer using `Router.events.pipe(filter(...))` and `takeUntil`. For form controls, you can use `takeUntil` or `unsubscribe` in ngOnDestroy. For services that expose observables, never subscribe inside the service itself unless you control the lifecycle (e.g., singleton service that lives forever). Instead, return the observable and let the consumer handle subscription.
A special case: `ActivatedRoute` params. Since the route can change within the same component (e.g., navigating from /users/1 to /users/2), you need to resubscribe. The standard pattern is to subscribe in ngOnInit and unsubscribe in ngOnDestroy. But be careful: if you use `takeUntil`, the subscription will be completed on destroy, but if the route changes, you need to re-subscribe. The cleanest approach is to use `ActivatedRoute.params.pipe(takeUntil(this.destroy$))` and handle the params change in the subscription callback. This works because the component is not destroyed on route param change (unless you configure the router to reuse components).
For large codebases, you can create a custom operator that logs or tracks subscriptions. For example, a `trace` operator that logs the caller's component name and subscription count. Or use `ngx-take-until-destroy` library which provides an `untilDestroyed` operator that automatically handles the notifier. However, be cautious: that library relies on ngOnDestroy being called, which is not guaranteed in all edge cases.
Another approach is to use Angular's `@ngrx/effects` pattern: if you use NgRx, effects are automatically managed and unsubscribed when the store is destroyed. But for vanilla Angular, a manual audit is still the best. I recommend running a memory profiler in CI: use Puppeteer to navigate through the app and take heap snapshots, then assert that the number of detached components is below a threshold. This catches leaks before they ship.
Frequently asked questions
Does the async pipe always prevent memory leaks?
Yes, the async pipe automatically unsubscribes when the component is destroyed. However, there is an edge case: if the observable emits synchronously during change detection, the async pipe can cause an 'ExpressionChangedAfterItHasBeenCheckedError'. Also, if you use the async pipe multiple times on the same observable without using `shareReplay`, you will create multiple subscriptions. But for memory leaks, async pipe is safe.
Can a memory leak occur from subscriptions inside services?
Absolutely. If a service is provided in a component (or lazy-loaded module) and subscribes to a long-lived observable (like Router events) in its constructor or a method called multiple times, each subscription accumulates. Since the service instance lives as long as the component, the subscriptions persist. The fix is to either unsubscribe in the service's ngOnDestroy (if the service implements OnDestroy) or avoid subscribing in the service altogether.
What is the difference between takeUntil and unsubscribe()?
`unsubscribe()` is a method on the Subscription object that manually tears down that specific subscription. `takeUntil` is an RxJS operator that completes the subscription when the notifier emits. The advantage of `takeUntil` is that you can apply it to multiple observables with a single notifier, and it's declarative. With `unsubscribe()`, you must store each Subscription and call unsubscribe in ngOnDestroy, which is more verbose and error-prone. `takeUntil` is preferred for multiple subscriptions.
How do I check for subscription leaks in unit tests?
In unit tests, use `fakeAsync` and `tick()` to simulate time. After the component is destroyed (by calling `fixture.destroy()`), you can assert that any custom subject is completed. For example, if you have a `destroy$` Subject, you can spy on it and verify that `next` and `complete` were called. Also, you can check that any mock observables have been unsubscribed by spying on their `subscribe` method and checking the returned Subscription's `closed` property.
What about using `first()` or `take(1)` — are they safe?
Yes, `first()` and `take(1)` are safe because they automatically complete after the first emission, which unsubscribes the subscriber. However, if the observable never emits (e.g., a Subject that never gets a value), the subscription will remain active indefinitely. So only use them when you are certain the observable will emit (e.g., HTTP requests, route params that are guaranteed to emit). For observables that may not emit, use `takeUntil` with a timeout as a safety net.