Advanced Techniques for Memory Management in Angular Applications
What are memory leaks, and how do we deal with them?
Memory Management plays a crucial role in developing efficient and high-performing Angular Applications. Angular applications are becoming increasingly more complex and feature-rich, and managing memory effectively is becoming more critical. Unused memory can lead to memory leaks if not properly released; thus, a decrease in performance, an increase in resource usage, and, worst case, even application crashes can occur.
It is crucial to understand the basics and implementation of advanced memory management techniques to build robust and optimised Angular applications ranging from small to enterprise scales.
This article aims to delve into the various causes of memory leaks and demonstrate various advanced techniques in easy-to-understand terms that will assist any developer when they are trying to overcome memory leaks.
The techniques that are being demonstrated will also assist in the prevention and optimisation of memory use within any Angular application.
The following techniques will be demonstrated: the async
pipe, leveraging the ngOnDestroy
Angular life-cycle hook, the Renderer2
service, as well as implementing various custom memory management strategies.
Performance Profiling and Code Optimisation will also be discussed, which can essentially be just as important and assist in the resolution of memory-related issues.
By implementing these advanced techniques, any Angular application can run efficiently, with optimised memory usage, and provide a higher-standard user experience.
Understanding Memory Leaks
A challenge common to Angular, known to most developing Angular applications, is the dreaded memory leaks. They often lead to unexpected behaviour as well as performance issues. As developers, we need to understand the various causes of memory leaks. Below are key concepts accompanied by code examples that will help you better understand memory leaks in Angular applications.
Advanced Memory Management Techniques
This section will discuss the advanced techniques that can be employed to improve and manage memory leaks, such as:
A. Unsubscribing from Observables,
B. Implementing proper event listener management,
C. Implementing Custom Memory Management Strategies,
D. Avoiding the use of Global Variables, and
E. Keeping the Document Object Model (DOM) Clean.
A. Unsubscribing from Observables
When handling asynchronous data streams, we tend to think of Observables, which are one of the main causes of memory leaks if not properly unsubscribed. Three approaches to unsubscribing are being discussed in this article, and they are as follows:
Approach 1: Using the async
pipe
This approach leverages a native feature within Angular that automatically unsubscribes from Observables when the component is destroyed. This feature is called the async
pipe. It is a simple yet effective process for managing subscriptions in components.
Here is an example of how to use the async
pipe:
import { Component } from '@angular/core';
import { Observable } from 'rxjs';
import { DataService } from 'src/app/services/data.service';
import { iData } from 'src/app/models/data';
@Component({
selector: 'app-unsubscribing-from-observables-async-pipe',
styleUrls: ['./unsubscribing-from-observables-async-pipe.component.scss'],
template: `
<h2>Unsubscribing from Observables using the Async Pipe</h2>
<div *ngIf="data$ | async as data">
<div *ngFor="let item of data">{{item.name}}</div>
</div>
`
})
export class UnsubscribingFromObservablesAsyncPipeComponent {
data$: Observable<iData[]>;
constructor(private dataService: DataService) {
this.data$ = this.dataService.getData();
}
}
Approach 2: Using thengOnDestroy
life-cycle hook
Another built-in feature of Angular is its life-cycle hooks. By utilising the ngOnDestroy
life-cycle hook, you can manually unsubscribe from Observables.
Here is an example of how to use the ngOnDestroy
life-cycle hook:
import { Component, OnDestroy } from '@angular/core';
import { Subscription } from 'rxjs';
import { iData } from 'src/app/models/data';
import { DataService } from 'src/app/services/data.service';
@Component({
selector: 'app-unsubscribing-from-observables-on-ng-destroy',
styleUrls: ['./unsubscribing-from-observables-on-ng-destroy.component.scss'],
template: `
<h2>Unsubscribing from Observables using OnNgDestroy</h2>
<div *ngIf="data">
<div *ngFor="let item of data">{{item.name}}</div>
</div>
`
})
export class UnsubscribingFromObservablesOnNgDestroyComponent implements OnDestroy {
private subscription$: Subscription;
data: iData[];
constructor(private dataService: DataService) {
this.subscription$ = this.dataService.getData()
.subscribe((data: iData[]) => {
this.data = data;
});
}
ngOnDestroy(): void {
this.subscription$.unsubscribe();
}
}
The example above uses a variable called subscription$
to keep track of a single data subscription. This variable is then manually unsubscribed by using the ngOnDestroy
life-cycle hook.
Approach 3: Using the push pipe, also known as the ngrxPush
pipe
To utilise this approach, you must install an additional library in your application. This library is called @ngrx/component
, and it is installed by using the following command: npm install @ngrx/component
.
This is widely seen as an alternative to the well-known async
pipe. Apart from looking similar on their template level, there are significant differences, where a key difference is that the ngrxPush pipe brings corrections forward on change detection control.
Both the async
pipe and the ngrxPush
pipe work as follows: a new value is passed through the data stream called an Observable, and the value then updates the variable being passed to the view. After this has been completed, it informs the markForCheck
method to inform the change detection mechanism system that the user view and template need to be re-rendered.
Thus, the Push Pipe is a drop-in replacement for the async
pipe. It triggers change detection in an Angular zone-less context, or it triggers the markForCheck
like the async
pipe in a zone context.
import { Component } from '@angular/core';
import { Observable } from 'rxjs';
import { DataService } from 'src/app/services/data.service';
import { iData } from 'src/app/models/data';
@Component({
selector: 'app-unsubscribing-from-observables-async-pipe',
styleUrls: ['./unsubscribing-from-observables-async-pipe.component.scss'],
template: `
<h2>Unsubscribing from Observables using the Push Pipe</h2>
<div *ngIf="data$ | async as data">
<div *ngFor="let item of data">{{item.name}}</div>
</div>
`
})
export class UnsubscribingFromObservablesAsyncPipeComponent {
data$: Observable<iData[]>;
constructor(private dataService: DataService) {
this.data$ = this.dataService.getData();
}
}
Other key differences between the async
pipe and the ngrxPush
(push) pipe to note are as follows:
- The
ngrxPush
pipe will not mark the host component as dirty when an observable emits various similar (exact) values in a row. - The
ngrxPush
pipe will not mark the host component as dirty when an observable emits values synchronously. - The
ngrxPush
pipe will trigger change detection when an observable emits a new value in zone-less mode.
B. Implementing Proper Event Listener Management
An often overlooked cause of memory leaks is event listeners. To manage these, you can make use of an Angular service called Renderer2
. This service is used to register and unregister event listeners, which, if managed properly, prevents memory leaks.
Here is an example of how to use Renderer2
to register and unregister an event listener:
import { Component, ElementRef, Renderer2 } from '@angular/core';
@Component({
selector: 'app-unregister-event-listeners',
styleUrls: ['./unregister-event-listeners.component.scss'],
template: `
<div>Events Triggered {{triggerCount}}</div>
<button type="button" (myClick)="$event">Trigger Click Event</button>
`
})
export class UnregisterEventListenersComponent {
// We define a function to store our event listener
private clickListener: () => void;
triggerCount: number = 0;
constructor(
private renderer: Renderer2,
private el: ElementRef
) { }
ngAfterViewInit() {
this.clickListener = this.renderer.listen(
this.el.nativeElement,
'click',
() => {
this.triggerCount++;
}
);
}
ngOnDestroy() {
this.clickListener();
}
}
In the example, the Renderer2
service is used to register a click event listener on the component’s native element. This is done in the ngAfterViewInit
life-cycle hook. The event listener is then unregistered using the function returned by the renderer.listen
in the ngOnDestroy
life-cycle hook, preventing memory leaks.
C. Implementing Custom Memory Management Strategies
Custom memory management strategies are sometimes required and can easily be implemented. Below are two examples of custom memory management strategies:
Example 1: Manual Subscription Management
Manually subscribing in components is possible, but you need to make sure that all subscriptions are dealt with appropriately. This is easily done by keeping track of all the subscriptions either in an array or by adding them all to one subscription.
Below is an example of adding all subscriptions on one subscription:
import { Component, OnDestroy } from '@angular/core';
import { iData } from 'src/app/models/data';
import { Subscription } from 'rxjs';
import { DataService } from 'src/app/services/data.service';
@Component({
selector: 'app-manual-subscription-management',
styleUrls: ['./manual-subscription-management.component.scss'],
template: `
<h2>Manual Subscription Management</h2>
<div *ngIf="data">
<div *ngFor="let item of data">{{item.name}}</div>
</div>`
})
export class ManualSubscriptionManagementComponent implements OnDestroy {
private subscription: Subscription;
data: iData[];
constructor(private dataService: DataService) {
this.subscription.add(
this.dataService.getData()
.subscribe((data: iData[]) => {
this.data = data;
})
);
}
ngOnDestroy(): void {
this.subscription.unsubscribe();
}
}
A subscription is used to keep track of all the subscriptions called subscription
. The life-cycle hook ngOnDestroy
is then used to manually unsubscribe from all the subscriptions as soon as the component is destroyed.
Example 2: Using the takeUntil
Operator
An Angular library called RxJS has an operator that can automatically unsubscribe from Observables when certain conditions are met. This is called the takeUntil
and you can see an example of a condition being set below.
import { Component, OnDestroy } from '@angular/core';
import { Subject } from 'rxjs';
import { DataService } from 'src/app/services/data.service';
import { takeUntil } from 'rxjs/operators';
import { iData } from 'src/app/models/data';
@Component({
selector: 'app-rxjs-take-until',
styleUrls: ['./rxjs-take-until.component.scss'],
template: `
<h2>Rxjs Take Until</h2>
<div *ngIf="data">
<div *ngFor="let item of data">{{item.name}}</div>
</div>
`
})
export class RxjsTakeUntilComponent implements OnDestroy {
private ngUnsubscribe$ = new Subject<void>();
data: iData[];
constructor(private dataService: DataService) {
this.dataService.getData()
.pipe(
takeUntil(this.ngUnsubscribe$)
)
.subscribe((data: iData[]) => {
this.data = data;
})
}
ngOnDestroy(): void {
this.ngUnsubscribe$.next();
this.ngUnsubscribe$.complete();
}
}
A local subject variable is created called ngUnsubscribe$
, and it is used to emit a value when the component is destroyed. When the value is emitted it triggers the takeUntil
operator to automatically unsubscribe from the Observable getData
.
D. Avoid Using Global Variables
Angular applications can also get memory leaks when global
variables are introduced. It is vital to avoid storing data in these variables due to them not being properly cleaned or taken care of, especially when components are destroyed. This is due to global
variables being persisted in memory.
Instead, a local component-level variable or service should be used to store and manage data within the Angular component tree.
If you absolutely need to use a global
variable, take note of the following best practices to manage them, as seen below:
- Limit the use of
global
variables:
One of the best practices to follow when developing Angular applications is limiting or avoiding the use ofglobal
variables. Data and functionality should be encapsulated as much as possible in components or services with isolated scopes. - Properly initialise and clean up
global
variables:
Make sure thatglobal
variables are initialised to an appropriate value wherever they are being used. Also, make sure that you clean these variables once they are not being used or once components are being destroyed. This involves setting the variable to eithernull
orundefined
and, in some cases, removing all references thereof. - Use Angular services for shared data:
Services are one of the most recommended alternatives to sharing data and functionality across components. By using services, you eliminate the use ofglobal
variables. Services encapsulate data and functionality in a controlled manner and can, therefore, be injected into various components as required. Take note: as soon as the service is no longer required, you must clean data and all references stored within the service. - Use Angular’s dependency injection:
By utilising Angular’s Dependency Injection, you can manage dependencies between components and services centrally. This is useful because it takes care of the creation and destruction of instances of services, ensuring the proper cleanup of resources. It, therefore, ensures data and functionality sharing between components by avoidingglobal
variables. - Make use of Angular’s life-cycle hooks:
Every component in Angular makes use of life-cycle hooks. Some of the more well-known hooks arengOnInit
,ngOnDestroy
, etc., which are dedicated to performing various tasks. These hooks can be utilised to properly initialise and clean up data and references within components, thus eliminating the use ofglobal
variables. - Avoid Circular Referencing:
Circular references do cause memory leaks because they create a reference cycle, which in turn prevents garbage collection. Therefore, you need to take care and make sure circular references are avoided between components, services or any other objects within your Angular application.
E. Keeping the DOM Clean
Keeping the Angular DOM clean is essential because, more often than not, the DOM is manipulated by Angular. Components need to be destroyed when they are no longer required.
An example of this would be to create DOM elements in a component dynamically. To keep the DOM clean, you should remove these elements from the DOM when the component is destroyed. This also includes references to these elements.
Performance Profiling and Optimisation
Memory leak management also includes profiling and optimising your application. This can be done by utilising various extensions and tools within internet browsers.
Some popular performance profiling tools in Angular include:
- Angular DevTools:
Angular DevTools is a popular internet browser extension for Google Chrome and Firefox. It provides developers with a set of specifically designed debugging and profiling tools for Angular. This allows developers to inspect component trees, view states for various components, track changes within the application and profiling performance. - Chrome DevTools
Google Chrome also offers its own built-in set of tools for profiling the performance of Angular applications. They provide developers with features for memory profiling, CPU profiling, as well as timeline recording. These all assist in the identification of memory-related performance issues. - Augury Internet Browser Extension:
Another popular extension used when debugging Angular applications is Augury. It provides a visual representation of the component tree, displaying component states and props, while it also offers performance profiling features such as memory profiling and CPU profiling.
Various additional Optimisation Techniques:
Once the performance issues have been identified, the following can be implemented to optimise your Angular application:
- Unsubscribe from unnecessary Observables in components or services when they are no longer required.
- Make use of Angular’s Change Detection Strategy called
onPush
. It is triggered when the properties of inputs change or when an event is triggered. - Lazy Load modules only when they are required. This assists in decreasing the load times of the application whilst optimising memory usage.
- Utilise Angular’s built-in library
Renderer2
. This service optimises and efficiently and directly manipulates the DOM, reducing performance issues. trackBy
should be used when collections are iterated on the DOM. This provides a function that returns a unique identifier for each item on the DOM. This assists in optimising the rendering of the list and reduces unnecessary updates to the DOM in Angular.
Conclusion
A critical aspect of building high-performance and reliable Angular applications is managing memory effectively.
In this article, we explored various advanced techniques to manage memory, including using pipes and life-cycle hooks. The importance of performance profiling and optimisation to help identify memory-related issues was also discussed.
In conclusion, proactively managing memory and optimising performance is vital for delivering fast, efficient, and reliable Angular Applications. By understanding and implementing the various techniques discussed in this article, developers can create web experiences that excel in memory usage and performance.
Stay mindful of memory management best practices and continuously monitor and optimise the performance of your Angular applications to provide an exceptional user experience.
Thank you for reading my article. I hope you learned something new and exciting and now have a better understanding of how memory leaks work and how to manage and prevent them.
Please feel free to leave a comment if you need clarification or if you have something to add. You can also find a complete GitHub repository for this topic below:
If you enjoyed this article, please check out the DVT Engineering space for more interesting articles and follow me for future topics.