Skip to content

HostListener/output() memory leak in SSR #66506

Open

Description

@mii-shell

Which @angular/* package(s) are the source of the bug?

Don't known / other

Is this a regression?

No

Description

It appears that elements are not being garbage collected in an SSR application if:

  • A child component contains an output() which it's parent is using
  • A component is using a @HostListener

How to reproduce in provided minimal sample

  • Either start the dev server or run a production build (both have the same result)
  • Open the app
  • Open dev tools
  • Click the toggle button multiple times
  • Take a snapshot of the heap
  • You now see multiple detached instances of both components which are still held in memory even though not used anymore
Image

Validation

  • Remove @HostListener from WithHostListener component
    // Change this
    @HostListener('click', ['$event'])
    public onClick(event: MouseEvent): void {}
    
    // to this
    public onClick(event: MouseEvent): void {}
  • Remove WithOutput component's output listener from App component
    <!-- change this -->
    <app-with-output (myOutput)="doSomething()"></app-with-output>
    
    <!-- to this -->
    <app-with-output></app-with-output>
  • Run the app
  • Click the toggle button multiple times
  • Take a heap snapshot
  • Now you can see that the DOM Nodes are cleaned up correctly
Image

Changing the WithHostListener component to cleaning up the click listener manually (as seen in the snippet below) also enables the GC to cleanup the app-with-host-listener nodes.

import { Component, ElementRef, inject, OnDestroy, Renderer2 } from '@angular/core';

@Component({
  selector: 'app-with-host-listener',
  template: `
    <div>With HostListener</div>
  `,
})
export class WithHostListener implements OnDestroy {
  private readonly renderer = inject(Renderer2);
  private cleanupListener: (() => void) | undefined;
  private readonly el = inject(ElementRef);

  constructor() {
    this.cleanupListener = this.renderer.listen(this.el.nativeElement, 'click', event => {
			this.onClick(event);
		});
  }

	public onClick(event: MouseEvent): void {console.log(event)}

  ngOnDestroy(): void {
		if (this.cleanupListener) {
			this.cleanupListener();
		}
  }
}

Please provide a link to a minimal reproduction of the bug

https://github.com/mii-shell/angular-dom-nodes-memory-leak

Please provide the exception or error you saw

No exception/error was thrown

Please provide the environment you discovered this bug in (run ng version)

Angular CLI       : 21.0.5
Angular           : 21.1.0-rc.0
Node.js           : 24.4.1
Package Manager   : npm 11.4.2
Operating System  : linux x64

┌───────────────────────────┬───────────────────┬───────────────────┐
│ Package                   │ Installed Version │ Requested Version │
├───────────────────────────┼───────────────────┼───────────────────┤
│ @angular/build            │ 21.1.0-rc.0       │ ^21.1.0-rc.0      │
│ @angular/cli              │ 21.0.5            │ ^21.0.5           │
│ @angular/common           │ 21.1.0-rc.0       │ ^21.1.0-rc.0      │
│ @angular/compiler         │ 21.1.0-rc.0       │ ^21.1.0-rc.0      │
│ @angular/compiler-cli     │ 21.1.0-rc.0       │ ^21.1.0-rc.0      │
│ @angular/core             │ 21.1.0-rc.0       │ ^21.1.0-rc.0      │
│ @angular/forms            │ 21.1.0-rc.0       │ ^21.1.0-rc.0      │
│ @angular/platform-browser │ 21.1.0-rc.0       │ ^21.1.0-rc.0      │
│ @angular/platform-server  │ 21.1.0-rc.0       │ ^21.1.0-rc.0      │
│ @angular/router           │ 21.1.0-rc.0       │ ^21.1.0-rc.0      │
│ @angular/ssr              │ 21.1.0-rc.0       │ ^21.1.0-rc.0      │
│ rxjs                      │ 7.8.2             │ ~7.8.0            │
│ typescript                │ 5.9.3             │ ~5.9.2            │
│ vitest                    │ 4.0.17            │ ^4.0.8            │
└───────────────────────────┴───────────────────┴───────────────────┘

Anything else?

  • I originally thought this problem might be related to Event replay memory leak #59261, which is why I've used Angular version 21.1.0-rc.0 in my minimal reproduction (which is the version in which this was fixed according to the changelog). Unfortunately, it did not resolve the issue.
  • I've also tested it with version 21.0.5, which resulted in the same
  • Manual garbage collection does not remove the elements from the heap either
  • Identical issue, regardless of whether @HostListener or host option in @Component is used
  • This issue does not occur in a basic Angular app setup without SSR

Activity

  1. added
    memory leakIssue related to a memory leak
    area: coreIssues related to the framework runtime
    on Jan 13, 2026
  2. added this to the needsTriage milestone on Jan 13, 2026
  3. JeanMeche commented on Jan 13, 2026

    @JeanMeche
    Member

    I can confirm this is directly related to event replay. Removing it resolved the leak.

  4. added a commit that references this issue on Jan 15, 2026
    3053b6e
  5. JeanMeche commented on Jan 15, 2026

    @JeanMeche
    Member

    fwiw, this is where the leak comes from:

    const clearStashFn = setStashFn(
    appId,
    (rEl: RNode, eventName: string, listenerFn: VoidFunction) => {
    // If a user binds to a ng-container and uses a directive that binds using a host listener,
    // this element could be a comment node. So we need to ensure we have an actual element
    // node before stashing anything.
    if ((rEl as Node).nodeType !== Node.ELEMENT_NODE) return;
    sharedStashFunction(rEl as RElement, eventName, listenerFn);
    sharedMapFunction(rEl as RElement, jsActionMap);
    },
    );

    Elements with listeners are retained by this callback until cleanup.

  6. added 10 commits that reference this issue on Jan 15, 2026
    3d0d00b
    daa5f01
    302c264
    d659ef0
    cf444ba
    408c763
    7a10506
    8685bb6
    a9e649e
    77fe7b0
  7. added a commit that references this issue on Feb 26, 2026
    7072fcd
  8. mii-shell commented on Aug 7, 2026

    @mii-shell
    Author

    @JeanMeche Thanks for investigating and providing a PR with a fix!

    Is there any news on when the fix will make it into a release?

    Due to this bug, we had to remove event replay for now. It would be great if we could enable it again any time soon 🙏

  9. thekhegay commented on Oct 23, 2026

    @thekhegay
    Contributor

    hit this on docs site of my ui lib - every visited route stays in memory. still reproducible on 22.2.3, and in v22 it got wider: provideClientHydration() now turns on incremental hydration by default and that brings withEventReplay() with it. so default ng new --ssr app leaks, only withNoIncrementalHydration() avoids it. plain template listeners leak too, not only @HostListener / output()

    prod build, prerendered, 200 <button (click)> per route, client side navigation between two routes, forced GC:

    config 10 / 20 / 40 visits used JS heap
    withEventReplay() (or v22 default) 2000 / 4000 / 8000 detached buttons 5.31 / 6.50 / 8.94 MB
    withNoIncrementalHydration() 200 / 200 / 200 4.13 / 4.14 / 4.15 MB

    retainer path from heap snapshot: stashEventListeners (module Map) - stash callback that withEventReplay() registers per APP_ID - its captured jsActionMap - '' block Set - detached element. '' set is deleted once at stability, evrything listenToDomEvent stashes after that stays until appRef.onDestroy

    #66594 looks like right fix, is anything blocking it?

  10. radomirchev commented on Oct 24, 2026

    @radomirchev

    Another data point, from a data grid inside one page (not route navigation). Data grids re-create their rows and cells while the user scrolls, filters or resizes, so every element that gets a template listener after hydration stays in the replay stash and the page keeps growing without any navigation.

    Prerendered page with an Ignite UI for Angular grid of about 50 rows, @angular/core 22.1.4, production build, Chrome, 12 narrow/wide re-layout cycles followed by 12 filter/clear cycles, garbage collection forced before each reading:

    JS heap DOM nodes
    with withEventReplay() 29 -> 77 MB 3,741 -> 64,308
    without it 29 -> 31 MB 3,737 -> 3,765

    The retained elements are the grid's rows, cells and chips (110 of 110 checked ones carried the replay stash; none without event replay).

    I compared the code in @angular/core 22.2.3 and 22.3.0-next.2 (compared, not run): the stash callback, the one-time jsActionMap.delete('') and the listenToDomEvent stash call are unchanged, and #66594 is still open.

  11. added a commit that references this issue on Oct 30, 2026
    bad5d8b
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions