Angular Root Services and Injector-Scoped Memory
Components unsubscribe correctly and still the heap grows with every page the user visits: the growth lives in services marked providedIn: 'root' that cache entities, keep BehaviorSubjects of per-page data, or register window listeners once and never remove them. This guide from Angular RxJS Subscription Memory Leaks, part of Framework-Specific Memory Optimization, explains how Angular’s injector hierarchy decides how long a service — and everything it holds — lives, and how to put state in the injector whose lifetime matches it.
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
Service Map/array grows with every visited record |
Root service caches per-page data forever | Bound the cache or move the service to a route/component injector | Memory plateaus or is freed on navigation |
BehaviorSubject retains the last huge payload of an old page |
Root-scoped subject keeps its last value indefinitely | Reset on leave, or scope the service to the page | Large payload released |
| Unmounted components retained via a service | Component registered a callback or this with a root service |
Deregister in DestroyRef.onDestroy |
Component instances collectable |
| Per-feature service instances pile up | Service provided in a component that is created repeatedly, never destroyed | Ensure host components are destroyed; implement ngOnDestroy |
Instances released with their component |
| Window/document listeners registered by services forever | Root service adds listeners at construction | Use them only in root services meant to live forever, or scope them | No accumulation from scoped services |
Root Cause: A Service Lives as Long as Its Injector
Angular creates service instances in injectors, and an instance lives exactly as long as the injector that created it. @Injectable({ providedIn: 'root' }) registers the service in the application’s root environment injector, which exists for the whole life of the app — so a root service is effectively a singleton global, with the same retention behaviour as a module-level variable, described in module-level caches and global singleton leaks. Every Map it fills, every array it appends to, every subject it holds and every callback registered with it stays reachable until the page unloads.
Services can instead be provided at narrower levels. A route’s providers array creates an environment injector for that route (and its children); in current Angular versions that injector is typically kept for the life of the app once created, so route-level providers give isolation but not necessarily release — check how your version and router configuration handle it. A component’s providers (or viewProviders) creates the service in the component’s element injector, which is destroyed with the component: the service’s ngOnDestroy runs, and it becomes collectable. That makes component-level providers the natural home for per-page or per-widget state that should disappear when the view does.
Leaks arise from mismatches. Per-page data stored in root services survives navigation: a DocumentStore that caches every opened document, a BehaviorSubject<Report | null> that holds the last 20 MB report, an analytics service that appends every event to an array “for batching” but never flushes. And component instances leak into root services when components register themselves — this.registry.add(this), callbacks passed to service.onChange(cb) — without deregistering. The RxJS subscription side of this is covered in unsubscribing observables to prevent Angular leaks; this guide is about the containers themselves.
Step-by-Step Fix
- Measure growth per navigation. Visit twenty records and take a heap snapshot; sort by retained size and look for service class names (
DocumentStore,AnalyticsService). Verification: a root service retains memory proportional to pages visited. - Classify each root service’s state. For every field, decide whether it is app-wide (auth, configuration, reference data) or page-scoped (current record, form drafts, per-view caches). Verification: you have a list of page-scoped state held at root.
- Move page-scoped state into component providers. Create a small state service provided in the page component’s
providersarray; inject it there and in child components. Verification: navigating away destroys the service (itsngOnDestroyruns). - Bound what must stay at root. Give root caches an LRU bound or TTL, and reset
BehaviorSubjects holding page data when the page is left. Verification: root service retained size plateaus. - Deregister components from services. Where components register callbacks or themselves with root services, remove them via
inject(DestroyRef).onDestroy(...). Verification: registries’ sizes equal the number of live components. - Re-measure the twenty-visit session. Repeat step 1. Verification: memory after twenty visits is close to memory after one.
Command and Code Reference
Use case: page-scoped state that is destroyed with its page.
// document-page.state.ts — NOT providedIn root
@Injectable()
export class DocumentPageState implements OnDestroy {
readonly doc = signal<DocumentModel | null>(null);
private readonly http = inject(HttpClient);
load(id: string) {
this.http.get<DocumentModel>(`/api/docs/${id}`).subscribe((d) => this.doc.set(d));
}
ngOnDestroy() {
this.doc.set(null); // release the payload promptly
}
}
// document-page.component.ts
@Component({
selector: 'app-document-page',
providers: [DocumentPageState], // element injector: lives and dies with this component
template: `<app-doc-view [doc]="state.doc()" />`,
})
export class DocumentPageComponent {
readonly state = inject(DocumentPageState);
constructor() {
inject(ActivatedRoute).paramMap
.pipe(takeUntilDestroyed())
.subscribe((p) => this.state.load(p.get('id')!));
}
}
Use case: a root service that must keep a cache — bounded, with deregistration.
@Injectable({ providedIn: 'root' })
export class PreviewCache {
private readonly max = 50;
private readonly cache = new Map<string, Preview>(); // insertion order = LRU order
private readonly listeners = new Set<() => void>();
get(id: string) {
const v = this.cache.get(id);
if (v) { this.cache.delete(id); this.cache.set(id, v); }
return v;
}
set(id: string, preview: Preview) {
this.cache.set(id, preview);
if (this.cache.size > this.max) this.cache.delete(this.cache.keys().next().value!);
this.listeners.forEach((l) => l());
}
onChange(listener: () => void) {
this.listeners.add(listener);
inject(DestroyRef).onDestroy(() => this.listeners.delete(listener)); // caller's lifetime
}
}
Verification and Regression Prevention
Verify with the same navigation-heavy session: root services’ retained sizes should plateau at their bounds, component-provided services should appear only for the currently displayed pages, and registries should hold one entry per live component. In a heap snapshot, filter by your service class names after navigating away; component-scoped services should be absent.
For prevention, adopt a rule in code review: providedIn: 'root' is for app-wide state; per-page state goes in component providers. Require bounds for any collection field in a root service, and prefer signals or subjects that are reset on page exit. A Playwright test that visits many records and checks heap growth, as in Playwright memory testing for single-page apps, catches regressions automatically.
Edge Cases and Gotchas
inject(DestroyRef) needs an injection context
inject() only works in constructors, field initialisers, factory functions and runInInjectionContext. In the onChange example it resolves the caller’s DestroyRef because the method is called from a component constructor; calling it elsewhere throws. Pass a DestroyRef explicitly when in doubt.
Lazy-loaded route injectors
Services provided in lazy-loaded routes are created when the route first loads and generally persist afterwards. They are not a substitute for component-scoped state when you need memory released on navigation.
Shared state between sibling components
When sibling components need the same page state, provide the service on their common parent component, not at root. The parent’s element injector gives both access and still ends with the page.
Signals and subjects hold their last value
signal() and BehaviorSubject both keep the latest value by design. In long-lived services, a single large last value can be the whole leak; set it to null when the value stops being relevant.
Frequently Asked Questions
Do Angular root services ever get destroyed?
Only when the application’s root injector is destroyed, which normally happens when the page unloads. For the lifetime of the app, a root service and everything it references stay in memory.
Where should per-page state live in Angular?
In a service provided in the page component’s providers array (or a signal store scoped the same way). It is created with the component, shared with its children through injection, and destroyed — with ngOnDestroy — when the component is destroyed.
Is ngOnDestroy called for services?
Yes, for services created in injectors that are destroyed: component element injectors and destroyable environment injectors. Root services’ ngOnDestroy runs only when the application itself is destroyed.
Can root services hold caches safely?
Yes, if the cache is bounded — by count, bytes or time — and holds compact data rather than component instances or DOM. Unbounded maps or arrays in root services are one of the most common Angular memory growth patterns.
Are NgRx or signal stores different?
Global stores registered at the root behave like root services: their state lives for the whole app. Feature stores provided on components (for example a component-level signal store) are destroyed with the component. The same rule applies — keep page-scoped state in component-provided stores and bound anything that must stay global.
How do I find which service is retaining memory?
Take a heap snapshot after navigation and sort by retained size; Angular service instances appear under their class names. Select the instance and inspect its fields in the Containment view to see which collection or subject holds the data.
Related
- Angular RxJS Subscription Memory Leaks — the parent topic
- Unsubscribing Observables to Prevent Angular Leaks —
takeUntilDestroyed,DestroyRefand the async pipe - Memory-Safe Caching Patterns in JavaScript — bounding caches that must stay global
- Framework-Specific Memory Optimization — the section overview