BUG-03 — paramMap Subscription Memory Leak on Patient Profile Navigation
Severity: High
Component: Patient Profile
File: src/app/patients/patient-profile/patient-profile.ts
Status: Open
Summary
The PatientProfileComponent subscribes to ActivatedRoute.paramMap in ngOnInit but never stores or unsubscribes the subscription. Every navigation between patient profiles creates a new, leaked subscription — causing accumulating background callbacks, potential duplicate data loads, and growing memory usage over a session.
Description
In ngOnInit, the component subscribes to route parameters to detect when the patient ID changes:
// patient-profile.ts — ngOnInit
ngOnInit(): void {
this.pageHeaderService.title.set('Patients');
this.pageHeaderService.subtitle.set('');
this.route.paramMap.subscribe((params) => {
const id = params.get('id');
if (id) {
this.patientId = parseInt(id, 10);
this.loadPatientData();
}
});
}
The subscription is:
- Never assigned to a class variable
- Never unsubscribed in
ngOnDestroy()
// patient-profile.ts — ngOnDestroy
ngOnDestroy(): void {
this.pageHeaderService.title.set('');
this.pageHeaderService.subtitle.set('');
// ← no subscription cleanup
}
Angular's ActivatedRoute.paramMap is a long-lived observable that does not complete automatically. When the component is destroyed (user navigates away), the subscription is not cleaned up. On the next visit to a patient profile, Angular creates a new component instance with a new subscription — while the previous subscription continues to exist in memory.
After visiting n patient profiles in a session, there are n active subscriptions on paramMap. When route params change (e.g., navigating from /patients/1 to /patients/2), all n subscriptions fire loadPatientData() — potentially in sequence, loading different patient data into the view multiple times.
Steps to Reproduce
- Open browser DevTools → Memory tab
- Navigate to
http://localhost:4200/#/patients/1
- Take a heap snapshot
- Navigate to
#/patients/2, then #/patients/3, then back to #/patients/1
- Take another heap snapshot
- Compare —
PatientProfileComponent instances and their closures remain referenced in memory
Alternatively:
- Add a
console.log inside the paramMap subscribe callback
- Navigate between several patient profiles
- On the 4th or 5th navigation, the console shows the callback firing multiple times per navigation
Result: Multiple stale subscriptions accumulate; loadPatientData() may fire multiple times per navigation.
Expected: Only one active subscription exists at any time; it is cleaned up on ngOnDestroy.
Root Cause
The subscription was created without being stored in a variable, making cleanup impossible. The ngOnDestroy hook was implemented (the class does implement OnDestroy) but only handles pageHeaderService reset — the developer forgot to wire up subscription cleanup.
Impact
- Memory leak: Component closures, including references to
patient, labResults, and injected services, are kept alive by the orphaned subscriptions.
- Duplicate data loads: After several navigations,
loadPatientData() fires multiple times simultaneously when changing patients, causing visible flicker or incorrect patient data briefly appearing.
- Degraded session performance: In a clinical environment where staff navigate between many patient profiles throughout a shift, memory and callback accumulation degrades app responsiveness over time.
Fix Specification
Option A — Store and unsubscribe manually (simple)
import { Subscription } from 'rxjs';
export class PatientProfileComponent implements OnInit, OnDestroy {
private paramsSub: Subscription | null = null;
ngOnInit(): void {
this.pageHeaderService.title.set('Patients');
this.pageHeaderService.subtitle.set('');
this.paramsSub = this.route.paramMap.subscribe((params) => {
const id = params.get('id');
if (id) {
this.patientId = parseInt(id, 10);
this.loadPatientData();
}
});
}
ngOnDestroy(): void {
this.pageHeaderService.title.set('');
this.pageHeaderService.subtitle.set('');
this.paramsSub?.unsubscribe();
}
}
Option B — Use takeUntilDestroyed() (preferred Angular 16+ pattern)
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { DestroyRef, inject } from '@angular/core';
export class PatientProfileComponent implements OnInit {
private destroyRef = inject(DestroyRef);
ngOnInit(): void {
this.route.paramMap
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((params) => {
const id = params.get('id');
if (id) {
this.patientId = parseInt(id, 10);
this.loadPatientData();
}
});
}
// ngOnDestroy no longer needed for subscription cleanup
}
Option B is recommended as it is the idiomatic Angular 16+/17+ pattern and removes the need for manual Subscription management. This codebase uses Angular 21 so takeUntilDestroyed is fully available.
Related Issues
- A similar pattern exists in
app.ts (ngOnInit) where router.events.subscribe() is also not stored. While the root App component is never destroyed in normal use, it is still a defect pattern that should be corrected for testability.
Acceptance Criteria
BUG-03 —
paramMapSubscription Memory Leak on Patient Profile NavigationSeverity: High
Component: Patient Profile
File:
src/app/patients/patient-profile/patient-profile.tsStatus: Open
Summary
The
PatientProfileComponentsubscribes toActivatedRoute.paramMapinngOnInitbut never stores or unsubscribes the subscription. Every navigation between patient profiles creates a new, leaked subscription — causing accumulating background callbacks, potential duplicate data loads, and growing memory usage over a session.Description
In
ngOnInit, the component subscribes to route parameters to detect when the patient ID changes:The subscription is:
ngOnDestroy()Angular's
ActivatedRoute.paramMapis a long-lived observable that does not complete automatically. When the component is destroyed (user navigates away), the subscription is not cleaned up. On the next visit to a patient profile, Angular creates a new component instance with a new subscription — while the previous subscription continues to exist in memory.After visiting n patient profiles in a session, there are n active subscriptions on
paramMap. When route params change (e.g., navigating from/patients/1to/patients/2), all n subscriptions fireloadPatientData()— potentially in sequence, loading different patient data into the view multiple times.Steps to Reproduce
http://localhost:4200/#/patients/1#/patients/2, then#/patients/3, then back to#/patients/1PatientProfileComponentinstances and their closures remain referenced in memoryAlternatively:
console.loginside theparamMapsubscribe callbackResult: Multiple stale subscriptions accumulate;
loadPatientData()may fire multiple times per navigation.Expected: Only one active subscription exists at any time; it is cleaned up on
ngOnDestroy.Root Cause
The subscription was created without being stored in a variable, making cleanup impossible. The
ngOnDestroyhook was implemented (the class does implementOnDestroy) but only handlespageHeaderServicereset — the developer forgot to wire up subscription cleanup.Impact
patient,labResults, and injected services, are kept alive by the orphaned subscriptions.loadPatientData()fires multiple times simultaneously when changing patients, causing visible flicker or incorrect patient data briefly appearing.Fix Specification
Option A — Store and unsubscribe manually (simple)
Option B — Use
takeUntilDestroyed()(preferred Angular 16+ pattern)Option B is recommended as it is the idiomatic Angular 16+/17+ pattern and removes the need for manual
Subscriptionmanagement. This codebase uses Angular 21 sotakeUntilDestroyedis fully available.Related Issues
app.ts(ngOnInit) whererouter.events.subscribe()is also not stored. While the rootAppcomponent is never destroyed in normal use, it is still a defect pattern that should be corrected for testability.Acceptance Criteria
paramMapsubscriptionsloadPatientData()fires exactly once per patient ID changePatientProfileComponentinstances remain referenced in a heap snapshot after navigation awayngOnDestroy(orDestroyRef) correctly cleans up the subscription