Dynamic Component and ViewContainerRef Leaks in Angular
A toast service, a dashboard that adds widgets at runtime, or a tooltip directive creates components dynamically — and each one leaves an instance behind: ngOnDestroy never runs, subscriptions stay open, and heap snapshots show growing counts of your widget’s class. This guide from Angular RxJS Subscription Memory Leaks, part of Framework-Specific Memory Optimization, explains which ways of creating components Angular cleans up for you and which require you to call destroy(), and how to make every dynamically created component end its life properly.
| Symptom | Root Cause | Immediate Action | Measurable Impact |
|---|---|---|---|
| Widget class instance count grows with each add/remove | ComponentRef discarded without destroy(); only DOM removed |
Keep refs; call ref.destroy() or vcr.remove() |
Instances and subscriptions released |
Toasts/dialogs attached to ApplicationRef never collected |
appRef.attachView() without detachView + destroy |
Destroy the ref (which detaches) when dismissing | Root-attached views released |
ngOnDestroy never runs for dynamic components |
Component never destroyed by Angular | Destroy through the ref or its container | Teardown logic runs |
Overlay panes accumulate in .cdk-overlay-container |
OverlayRef not disposed |
Call overlayRef.dispose() on close |
Pane DOM and portal released |
| Host container destroyed but its dynamic children persist elsewhere | Children created in a different container or attached to app | Create them in the host’s ViewContainerRef |
Children destroyed with host |
Root Cause: Angular Destroys What It Owns — and Dynamic Components Are Owned by Their Container
Components declared in templates are created and destroyed by Angular as the template changes. Dynamically created components are owned by whatever you attached them to. There are three common patterns, with different cleanup rules.
ViewContainerRef.createComponent() inserts the new component into a view container. The container owns it: vcr.clear(), vcr.remove(index) or destroying the host view destroys the component, runs its ngOnDestroy, and tears down its bindings. You still need to destroy it yourself when removing it individually — deleting its DOM element or dropping the ComponentRef does not destroy it. If the container lives for the whole app (a root outlet used by a toast service, for instance), components added to it accumulate until explicitly removed.
Standalone createComponent() with ApplicationRef.attachView() creates a component outside any container and attaches its host view to the application for change detection — the usual approach for appending components to document.body. Nothing else owns it: it lives until you call componentRef.destroy() (which also detaches it). Removing the host element from the DOM leaves the instance attached to ApplicationRef, still checked on every change detection and still holding every subscription and reference — a classic detached DOM plus live-component leak.
CDK Overlay creates panes in a global container and attaches portals to them. The OverlayRef owns the pane; overlayRef.detach() removes the portal content but keeps the pane for reuse, while overlayRef.dispose() removes everything. Services that create a new overlay per open and only detach() on close leave empty panes and their bookkeeping behind — the same pattern as React portals and modal memory leaks.
In all three, a live ComponentRef keeps the component instance, its injector, its subscriptions and its DOM reachable. The fix is always to destroy through Angular, not to remove DOM.
Step-by-Step Fix
- Count instances across add/remove cycles. Add and remove the dynamic component twenty times, then take a heap snapshot and filter by its class name. Verification: instance count equals the number currently displayed, or it grows.
- Find the creation pattern. Locate
createComponentcalls and determine whether they use aViewContainerRef,ApplicationRef.attachView, or an overlay. Verification: you know which cleanup call is required. - Keep every ref. Store
ComponentRefs (orOverlayRefs) in the service or host that created them, keyed by an ID. Verification: you can reach each live dynamic component from its owner. - Destroy on removal. On dismiss/remove, call
ref.destroy()(orvcr.remove(vcr.indexOf(ref.hostView))), andoverlayRef.dispose()for overlays; delete the stored ref. Verification: the component’sngOnDestroyruns and the class count drops. - Destroy all on owner teardown. In the owner’s
ngOnDestroy(orDestroyRef.onDestroy), destroy any remaining refs. Verification: destroying the host leaves no instances behind. - Re-run the twenty cycles. Repeat step 1. Verification: instance count and
.cdk-overlay-containerchildren return to baseline.
Command and Code Reference
Use case: a toast service that owns and destroys its components.
@Injectable({ providedIn: 'root' })
export class ToastService {
private readonly appRef = inject(ApplicationRef);
private readonly env = inject(EnvironmentInjector);
private readonly live = new Map<number, ComponentRef<ToastComponent>>();
private nextId = 0;
show(message: string, ms = 5000) {
const id = this.nextId++;
const ref = createComponent(ToastComponent, { environmentInjector: this.env });
ref.setInput('message', message);
this.appRef.attachView(ref.hostView); // change detection
document.body.appendChild(ref.location.nativeElement);
this.live.set(id, ref);
setTimeout(() => this.dismiss(id), ms);
return id;
}
dismiss(id: number) {
const ref = this.live.get(id);
if (!ref) return;
ref.destroy(); // detaches, runs ngOnDestroy, removes DOM
this.live.delete(id);
}
}
Use case: widgets in a host’s view container, destroyed with the host.
@Component({ selector: 'app-dashboard', template: `<ng-container #slot />` })
export class DashboardComponent {
@ViewChild('slot', { read: ViewContainerRef, static: true }) slot!: ViewContainerRef;
private readonly widgets = new Map<string, ComponentRef<unknown>>();
add(id: string, type: Type<unknown>) {
this.widgets.set(id, this.slot.createComponent(type)); // owned by this container
}
remove(id: string) {
this.widgets.get(id)?.destroy(); // destroy, not just hide
this.widgets.delete(id);
}
// No ngOnDestroy needed: destroying the dashboard destroys its container's views
}
Use case: an overlay that is disposed, not merely detached.
open() {
this.overlayRef = this.overlay.create({ hasBackdrop: true });
this.overlayRef.attach(new ComponentPortal(PickerComponent));
this.overlayRef.backdropClick().pipe(take(1)).subscribe(() => this.close());
}
close() {
this.overlayRef?.dispose(); // removes the pane, portal and listeners
this.overlayRef = undefined;
}
Verification and Regression Prevention
A dynamic-component fix is verified when repeated add/remove cycles leave the class instance count equal to the number of components on screen, ngOnDestroy logs appear for each removal, and the overlay container’s child count returns to baseline. Also confirm that change detection work does not grow: components left attached to ApplicationRef are checked on every cycle, so a leak here slows the whole app over time.
Encapsulate dynamic creation in services or hosts that keep refs and expose explicit remove/dismiss methods; avoid ad-hoc createComponent calls scattered across components. A Playwright test that triggers many toasts or widget adds and checks heap growth, plus a Memlab scenario for retainer traces as in finding leaks with Memlab scenarios, catches regressions.
Edge Cases and Gotchas
Inputs set via setInput versus instance properties
ref.setInput() participates in change detection correctly; assigning to ref.instance properties does not trigger it and can lead to code that keeps extra references to “refresh” components manually. Prefer setInput.
Output subscriptions
Subscribing to a dynamic component’s outputs (ref.instance.closed.subscribe(...)) creates a subscription from your service to the component. Output subscriptions end when the component is destroyed, but if the handler captures large state in the service, keep it minimal.
Embedded views from templates
vcr.createEmbeddedView(templateRef) follows the same ownership rules as components: remove or clear them, or destroy the returned EmbeddedViewRef, when no longer needed.
Server-side rendering
On the server, dynamically created components attached to ApplicationRef must also be destroyed per request, or they accumulate across requests in the long-lived server process.
Frequently Asked Questions
Does removing a component’s DOM element destroy it in Angular?
No. Angular components are destroyed through their ComponentRef, their view container, or their parent view. Removing the host element from the DOM leaves the instance alive, its subscriptions open and, if attached to ApplicationRef, still part of change detection.
When is ViewContainerRef.clear enough?
When the container’s components should all be removed together, for example when switching a dashboard layout. clear() destroys every view in the container. To remove one component, destroy its ref or remove it by index.
What is the difference between OverlayRef.detach and dispose?
detach() removes the attached portal content but keeps the overlay pane for reuse. dispose() removes the pane, its backdrop and all listeners. Services that create a new overlay per open should dispose on close.
How do I find leaked dynamic components?
Take a heap snapshot after repeated create/remove cycles and filter by the component’s class name. More instances than are visible indicates leaked ComponentRefs; their retainers usually lead to ApplicationRef views or a long-lived container.
Does a destroyed component’s DOM disappear automatically?
For components created in a view container, destroying the ref removes their host element from the container. For components attached with ApplicationRef.attachView and appended to the body manually, destroy() also removes the host element in current Angular versions; if you moved the element elsewhere yourself, verify it is gone and remove it if needed.
Why does change detection get slower as leaks accumulate?
Every view attached to ApplicationRef is checked on each change detection cycle. Leaked root-attached components add work to every cycle, so a slow leak shows up as gradually rising scripting time in performance traces as well as memory growth.
Do standalone components change these rules?
No. Standalone components can be created with the same APIs and follow the same ownership rules; only module declarations are no longer needed.
Related
- Angular RxJS Subscription Memory Leaks — the parent topic
- Angular Signals: effect() Cleanup and Memory — cleanup for reactive side effects
- Third-Party Widgets Leaving Detached DOM Behind — the framework-agnostic version of this problem
- Framework-Specific Memory Optimization — the section overview