TL;DR - Remember to clean up your Rx subscriptions. In my experience, this is by far the most common cause of memory leaks in Angular applications.
A memory leak is one of the worst types of issues you can have. It’s hard to find, hard to debug, and often hard to solve. As a developer, it’s essential to know how memory leaks are created and how to deal with them. It could be criteria to differentiate between a good and an-average-developer. At a certain time, you started wondering how the memory is managed in the browser. This knowledge is especially important once your application reaches a certain size.
Memory leaks occur in every programming language or framework, including Angular.
Low-level languages like C, have manual memory management primitives such as malloc() and free(). In contrast, JavaScript automatically allocates memory when objects are created and frees it when they are not used anymore (garbage collection). This automaticity is a potential source of confusion: it can give developers the false impression that they don’t need to worry about memory management.
Regardless of the programming language, the memory life cycle is pretty much always the same:
The second part is explicit in all languages. The first and last parts are explicit in low-level languages but are mostly implicit in high-level languages like JavaScript. The majority of memory management issues occur at the third phase - to release the allocated memory. The most difficult aspect of this stage is determining when the allocated memory is no longer needed.
For more detail on how JS memory allocation works, see Memory Management.
Memory leaks most often arise over time when components are re-rendered multiple times, e.g through routing or by using the *ngIf
directive, or you are listening to a WebSocket connection in the background and update the UI subsequently. For example, when a user works a whole day on our application without refreshing the browser and it is becoming increasingly slower.
When the application started to get slower, we tended to reload the browser. By doing so, the browser release all the memory accumulated from the beginning, and our application started fresh again. But what if there are memory leaks happened and you didn’t even notice, so the QA team.
To mimic a scenario, I created a setup with two components, ParentMemoryLeakedComponent
and ChildMemoryLeakedComponent
.
export class ChildMemoryLeakedComponent implements OnInit, OnDestroy {
componentId: number;
public counter: number;
public counterSubscription: Subscription;
ngOnInit() {
this.componentId = new Date().getTime();
this.counterSubscription = timer(0, 1000)
.pipe(tap(counter =>{
console.log(`Counter ${this.componentId} ${counter}`);
}))
.subscribe(counter => {
this.counter = counter;
});
}
ngOnDestroy() {
console.log(`Counter ${this.componentId} stopped at ${this.counter}`);
}
}
The child component has a timer to run every 1 second and do console.log
. You will see the counter on the template as well. I also set the uniqueId for the component based on the current date by using new Date().getTime()
.
@Component({
selector: "parent-leaked",
template: `
<h2 class="mb-5">Memory Leaked - example</h2>
<button class="btn btn-primary mr-1" (click)="relive()">
Relive
</button>
<button class="btn btn-danger" (click)="destroy()">
Destroy
</button>
<child-leaked *ngIf="isAlive"></child-leaked>
`
})
export class ParentMemoryLeakedComponent {
isAlive = false;
destroy() {
this.isAlive = false;
}
public relive() {
this.isAlive = true;
}
}
The parent has two buttons to toggle a flag. The child component will be displayed based on that flag.
See my demonstration below, you will understand how easily we can create a memory leak in Angular by not unsubscribe from the subscription.
The flow:
Relive
button, the child component is renderedDestroy
. The child component is disappeared from the UI, but the console.log
keeps running. I can see two different component ids on the consoleRelive
and Destroy
back end forth multiple times, the console.log
keeps repeating multiple times. Think about it this way. If you click 100 times, you will see 100 console.log
every secondStress test:
I opened the Chrome Performance monitor and started to click Relive
and Destroy
continuously. Noticed that the CPU usage and heap size is increasing so quickly. Up to a point when I clicked the button, but there was a long delay before the browser picked my action, It is only like 40 seconds after I started the test. I have to close the browser afterwards.
The easiest way to fix the problem above is to clean the subscription when you destroy the component, or when you don’t need it anymore.
ngOnDestroy() {
this.counterSubscription.unsubscribe();
console.log(`Counter ${this.componentId} stopped at ${this.counter}`);
}
A single line of code this.counterSubscription.unsubscribe()
could save you from a lot of problems in the future…
See the below screenshot when I unsubscribe properly from the subscription. There are no memory leaked occurred.
There are multiple ways to clean up Rx subscription. You could do as my example by assigning the subscription to a variable and then call unsubscribe()
when you don’t need it. But if your component has many subscriptions as below code, your code will be ugly very soon.
export class LuckyComponent implements OnInit, OnDestroy {
private mySubscription1: Subscription;
private mySubscription2: Subscription;
private mySubscription3: Subscription;
private mySubscription4: Subscription;
private mySubscription5: Subscription;
private mySubscription6: Subscription;
public ngOnInit(): void {
/*
subscribing to multiple observables comes here
*/
}
public ngOnDestroy(): void {
this.mySubscription1.unsubscribe();
this.mySubscription2.unsubscribe();
this.mySubscription3.unsubscribe();
this.mySubscription4.unsubscribe();
this.mySubscription5.unsubscribe();
this.mySubscription6.unsubscribe();
}
I recommend to use:
I personally use until-destroy
in my project at Zyllem.
Usually, we don’t write an application just to do console.log
as my above example. I hope that simplicity can help you to understand the problem easier. Some of the real-world use cases that I have seen during development could be:
It is very similar to my example, but instead of the timer run every second. You do as below
export class ChildDetailComponent implements OnInit, OnDestroy {
constructor(private _route: ActivatedRoute){
this._route.params.subscribe(this.onParamsChange.bind(this));
}
onParamsChange(params){
let carId = params["carId"];
//call API to get car detail by carId
}
}
I saw my team always got that problem when we build master-detail UI where we have:
carId
to get the detail data from the APIIf I open different cars with different Id, It creates a memory leak every time I instantiate the component. onParamsChange
will be executed x times where x is the number of times ChildDetailComponent
get initialized.
To fix it, simply unsubscribe from the route event on component destroy.
export class ChildDetailComponent implements OnInit, OnDestroy {
subscription: Subscription;
constructor(private _route: ActivatedRoute){
this.subscription = this._route.params.subscribe(this.onParamsChange.bind(this));
}
onParamsChange(params){
let carId = params["carId"];
//call API to get car detail by carId
}
ngOnDestroy(){
this.subscription.unsubscribe();
}
}
Very similarly, WebSocket connections must always be closed when unused. I have prepared a simple Websocket component to display crypto prices. You can see the detail inside stackblitz.
const Endpoint = "wss://ws.coincap.io/prices/";
@Component({
selector: "coin-price",
template: `
{{ id | titlecase }}: {{ (price$ | async) || "loading..." }}
`
})
export class CoinPriceComponent {
@Input() id: string;
public price$ = new Subject();
private webSocket: WebSocket;
ngOnInit() {
const id = this.id;
this.webSocket = new WebSocket(this.getEndpoint(id));
this.webSocket.onmessage = (msg: MessageEvent) => {
const data = JSON.parse(msg.data);
let price = data[id];
console.log(`${id}: ${price}`)
this.price$.next(price);
};
}
ngOnDestroy() {
//Remove this line to see memory leak happen.
this.webSocket.close();
}
private getEndpoint(id: string) {
return `${Endpoint}?assets=${id}`;
}
}
If I remove this line this.webSocket.close()
, you will see the memory leak.
Another common cause of memory leaks is DOM events that are never unregistered. Some folks may think that using Angular Renderer may take care of it, but that is only the cause if the events are defined in the template, just as with the async pipe.
Let’s see a quick and common example of a component that registers a scroll listener on the body, without un-register the event:
@Component({...})
export class ScrollComponent {
constructor(private renderer: Renderer2) {}
ngOnInit() {
this.renderer.listen(document.body, 'scroll', () => {
this.updatePosition();
});
}
updatePosition() { /* implementation */ }
}
This does, indeed, create a memory leak every time we instantiate ScrollComponent
— so let’s fix it:
@Component({...})
export class ScrollComponent {
private listeners = [];
constructor(private renderer: Renderer2) {}
ngOnInit() {
const listener = this.renderer.listen(
document.body,
'scroll',
() => {
this.updatePosition();
});
this.listeners.push(listener);
}
ngOnDestroy() {
this.listeners.forEach(listener => listener());
}
updatePosition() { /* implementation */ }
}