top of page
Search
  • Writer's pictureZsolt

How much do our Angular projects leak memory exactly?


Let’s see the basics first. We have an Angular application (v17 in this case, could be any other), 2 routes, and 2 standalone components on the routes. When we go back and forth the memory is not increasing, as it shouldn’t. Well if you don’t count the leak I found in Angular routing.



That does not make the memory increase constantly still, it just keeps 1 instance of the component in memory when it shouldn’t. But do you find anything odd in this innocent component?

@Injectable({ providedIn: 'root' })
class ConfigService {
  private config = { someProp: 'A', otherConfig: 'B' };
  config$ = interval(5000).pipe(
    map((i) => (i % 2 ? this.config.someProp : this.config.otherConfig)),
  );
}

@Component({
  selector: 'app-second',
  standalone: true,
  imports: [RouterModule, ChildComponent, AsyncPipe],
  template: `
    <a routerLink="..">TO First component</a>

    @for(e of (config$ | async); track e) {
    <p>{{ e }}</p>
    }
  `,
})
export class SecondComponent {
  configService = inject(ConfigService);
  config$ = this.configService.config$.pipe(shareReplay(1))
}

What’s that shareReplay doing there?

shareReplay quick recap

As you might know share is used to multicast emissions so that the same pipe does not need to run for each subscriber. Very practical when you don’t want to call the backend for each subscriber or execute some CPU-intensive tasks for each. I mimic both with this beautiful ConfigService. shareReplay also provides the last emitted value(s) for late subscribers. It does that by subscribing by itself on a ReplaySubject and proxying out the emissions. If the source ( config$ in the example ) does not complete, the inner subject will remain subscribed even after SecondComponent is destroyed, though SecondComponent correctly uses async pipe for subscription management. The leak would cease to exist if we used refCount: true. But this article is about the severity of leaks.

Leaking component protection

This is not a new topic. There are plenty of ways to manage subscriptions, I remember first learning about this from Netanel Basal’s article, so let me refer you to it as well.



As you probably know when you subscribe to an observable or event in JavaScript, you usually need to unsubscribe at a…netbasal.com

He also refers to the great article, the baseline for ways to unsubscribe from Observables: merge() and subscribe, use one subscription and add() the others, use async pipe, use takeUntil. Now of course we even have takeUntilDestroyed .



But what if you happened to let a subscription open, like apparently I did in this example with shareReplay . As you can see I added some huge data to the component so it’s straightforward to see if it’s retained in the memory.

What is leaking exactly

Curiously enough, though it leaks a few kB-s every few clicks it sure does not retain this huge component and leak it at every click. This is because route deactivate will ultimately assign null to the object referencing the component, and since GC is smart that way, this release will cascade to all objects and values that are only referenced by this component.

Let’s make it worse

I didn’t do much, added a new property, and logged it. It won’t do much more harm, would it?

export class SecondComponent {
  configService = inject(ConfigService);
  config$ = this.configService.config$.pipe(
    tap(() => console.log(this.shortArr))
    shareReplay(1),
  );

  shortArr = [0, 1, 2];
  longStr = JSON.stringify(new Array(10000000).fill(Math.random() * 1000));
  longArr = new Array(5000).fill(Math.random() * 1000);
}

Except that it did. As we reference this , so the component itself, we now have a reference to it after Router kills the component. Naturally, we have a reference to everything else inside the component, including the big properties.


With forced GC in the end

Remember that we don’t have a subscription for config$ after destroy, only the inner subscription of shareReplay ? If I were to change the order of tap and shareReplay the reference to the component wouldn’t be retained, as it would not be part of shareReplay ‘s source.

Even worse?

In the final version I replace shareReplay with a good old subscription, that I “forgot” to unsubscribe from, because I also replaced the async pipe, and instead of the config @for now lists the items of longArr .

The example is extreme, but we’re talking about a simple div and a list of paragraph elements. Now that we have a reference to that as well, the DOM nodes are also retained in the memory, and we’ve managed to leak 10 MB more 👏.


Please if you liked the Post, give it a clap on Medium https://itnext.io/how-much-do-our-angular-apps-leak-memory-exactly-3ff1afd660cb

28 views0 comments

Comments


Commenting has been turned off.
bottom of page