Detecting and Fixing Duplicate RxJS Emissions in Long‑Lived Angular Services and Components
Learn how to spot duplicate RxJS emissions, identify the root causes, and apply targeted fixes to prevent memory leaks in long‑lived services and components.
01 Jul 2026, 14:43 UTC

Recognizable condition
Subscribers see duplicate emissions, memory usage rises, or callbacks execute after the component or service has been destroyed. The issue is evident when the same value is emitted multiple times for a single logical event, or when the application slows down because observers remain alive.
Diagnostic table
| Typical cause | Observable symptom | Common location |
|---|---|---|
| Subscriptions never unsubscribed | Duplicate emissions, growing heap | Services or components that create observables in constructors or ngOnInit |
| shareReplay without refCount | Source stays subscribed, side‑effects keep running | Utility functions that cache values |
| Subject used as multicast without completion | Late subscribers receive no emission or errors | Subjects created in long‑lived services |
| Closure captures component instance | Callbacks run after destruction | Event handlers that capture `this` |
Ordered checks
- Confirm RxJS version: run `npm list rxjs` in the project root. The guide assumes RxJS 6.x or 7.x with pipeable operators. If the version is older, upgrade first.
- Inspect subscription lifecycle: add a `console.log` in the subscription callback and in the `finalize` operator. Example:
Check that the finalize log appears only once per mount/unmount cycle.this.service.data$.pipe( finalize(() => console.log('subscription finalized')), subscribe(value => console.log('emitted', value)) ) - Check for missing unsubscribe: search the codebase for `new` followed by a pipe or `from` and verify that each `Subscription` reference is stored and that `unsubscribe()` is called in `ngOnDestroy` (Angular) or an appropriate cleanup hook.
- Validate shareReplay configuration: ensure any `shareReplay()` call includes `{ refCount: true }`. Without refCount the source stays active even after the last subscriber leaves.
- Examine Subject usage: if a `Subject` is used as a multicast source, confirm that `next()`, `error()`, or `complete()` are called, and that late subscribers are not left waiting indefinitely.
- Run a repeat‑mount test: create a minimal component that mounts and unmounts repeatedly (e.g., via a button that toggles a view). Log inside `finalize` and observe that the teardown runs each time.
- Compare heap snapshots: take DevTools heap snapshots before and after several mount/unmount cycles. Look for retained `Observer` objects or closures that reference the component.
- Practical way to check the result: after fixing, run the component repeatedly and verify that the console shows exactly one finalize log per mount/unmount cycle. If you see multiple logs, the cleanup is incomplete.
Fixes tied to findings
- Unsubscribe in lifecycle: store the subscription in a class property and call `subscription.unsubscribe()` in `ngOnDestroy`. Example:
This prevents observers and closures from staying alive after the component is destroyed.private dataSub!: Subscription; ngOnInit() { this.dataSub = this.service.data$.subscribe(v => this.handle(v)); } ngOnDestroy() { this.dataSub?.unsubscribe(); } - Use shareReplay with refCount for cached values that should release the source when no one is listening:
When the last subscriber unsubscribes, the source completes and side‑effects stop, avoiding indefinite memory growth.this.cached$ = this.source.pipe( shareReplay({ bufferSize: 1, refCount: true }) ); - Automatic unsubscribe with takeUntil in component controllers:
The `takeUntil` operator unsubscribes automatically when the component is removed.private destroyed$ = new Subject(); ngOnInit() { this.service.data$.pipe( takeUntil(this.destroyed$) ).subscribe(v => this.handle(v)); } ngOnDestroy() { this.destroyed$.next(); this.destroyed$.complete(); } - Correct Subject lifecycle: if a `Subject` is used for broadcasting, call `complete()` when the service is destroyed or when the last consumer unsubscribes. Also ensure `error()` is handled to avoid silent failures.
- Avoid premature unsubscribe: do not call `unsubscribe()` inside the same subscription’s callback unless the intention is to stop after the first emission. Doing so can mask subsequent emissions and lead to missed data.
Escalation criteria
If duplicate emissions persist after applying the above fixes, investigate deeper causes:
- Custom operators that re‑emit values or contain their own subscriptions.
- Closures that capture the component instance and keep it alive through asynchronous callbacks.
- Zone.js microtask scheduling that delays unsubscription until after the next tick.
Verification steps
- Confirm the installed RxJS version with `npm list rxjs`.
- Create a test component:
Add a subscription that logs `finalize` and `emitted`. Toggle the button repeatedly and verify that the finalize log appears once per toggle.@Component({template: 'Toggle'}) export class ToggleComponent { private show = false; ngOnInit() { this.toggle(); } toggle() { this.show = !this.show; } } - Open Chrome DevTools, select the “Memory” tab, take a heap snapshot after mounting the component, then unmount and mount again. Compare snapshots; the number of `Observer` entries should not increase.
- Run the application in a production‑like environment and monitor memory usage over time. A steady rise indicates retained observers.
Summary
Duplicate RxJS emissions usually stem from forgotten unsubscriptions or mis‑configured sharing operators. By systematically checking version, subscription lifecycle, shareReplay settings, and Subject handling, you can eliminate the symptom and prevent memory leaks. Use the provided code snippets and verification steps to confirm that cleanup occurs as expected, and escalate only when the root cause lies outside basic RxJS patterns. Remember that proper cleanup is a habit, not a one‑time fix.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.