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.

Who destroys a dynamic component? ViewContainerRef.createComponent: the container destroys it on clear, remove or when the host is destroyed; removing DOM alone does not. createComponent with ApplicationRef.attachView: only componentRef.destroy destroys it; otherwise it stays attached to the application forever. CDK Overlay: overlayRef.dispose removes pane and content; detach alone keeps the pane. vcr.createComponent() owned by the container destroyed by: vcr.clear(), vcr.remove(i), host destroy, ref.destroy() not by removing DOM createComponent + attachView attached to ApplicationRef destroyed only by: componentRef.destroy() otherwise lives forever CDK Overlay owned by OverlayRef detach(): content gone, pane kept for reuse dispose(): everything gone new overlay per open → dispose

Step-by-Step Fix

  1. 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.
  2. Find the creation pattern. Locate createComponent calls and determine whether they use a ViewContainerRef, ApplicationRef.attachView, or an overlay. Verification: you know which cleanup call is required.
  3. Keep every ref. Store ComponentRefs (or OverlayRefs) in the service or host that created them, keyed by an ID. Verification: you can reach each live dynamic component from its owner.
  4. Destroy on removal. On dismiss/remove, call ref.destroy() (or vcr.remove(vcr.indexOf(ref.hostView))), and overlayRef.dispose() for overlays; delete the stored ref. Verification: the component’s ngOnDestroy runs and the class count drops.
  5. Destroy all on owner teardown. In the owner’s ngOnDestroy (or DestroyRef.onDestroy), destroy any remaining refs. Verification: destroying the host leaves no instances behind.
  6. Re-run the twenty cycles. Repeat step 1. Verification: instance count and .cdk-overlay-container children return to baseline.
Toast instances after 200 notifications A toast service that appends toasts to the body with attachView and removes only the DOM element after five seconds retains all 200 toast component instances and their subscriptions. After switching to componentRef.destroy on dismissal, retained instances equal the number currently visible, at most 3. 200 0 DOM removed, ref never destroyed componentRef.destroy() on dismiss toasts shown (0 → 200)

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.

Add/remove cycle checks After repeated add and remove cycles, the component class instance count in a heap snapshot equals the components on screen, an ngOnDestroy log appears for each removal, and the overlay container’s child count returns to baseline. Components left attached to ApplicationRef also slow every change detection cycle. After repeated add/remove cycles Instance count Equals the dynamic components currently on screen. ngOnDestroy logs One for every removal, including dismiss via route change. Overlay container Child count back at baseline; nothing left attached to ApplicationRef.

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.