How I Fixed a Memory Leak in My Angular App

Memory leaks are one of those frustrating problems that can silently wreak havoc on your web applications. They lead to performance degradation, slow down your app over time, and—if left unchecked—can eventually cause your app to crash. When I discovered a memory leak in my Angular app, I knew I had to dig deep to understand the root cause and find an effective solution.

In this article, I’ll walk you through the journey of identifying, debugging, and ultimately fixing a memory leak in an Angular application. Along the way, I’ll share lessons learned, tools used, and strategies that you can apply to your own projects to prevent or resolve memory leaks efficiently.

What Is a Memory Leak?

Before we dive into how I fixed the leak, let’s quickly define what a memory leak is. A memory leak happens when your application keeps references to objects that are no longer needed, preventing the JavaScript garbage collector from freeing up that memory. Over time, as more objects are held in memory unnecessarily, your application’s performance degrades.

In the case of a single-page application (SPA) like those built with Angular, memory leaks are particularly problematic because the user interacts with the same page for extended periods of time. Without proper cleanup, memory usage can grow uncontrollably, leading to slowdowns or crashes.

Discovering the Memory Leak

The first sign of a problem in my Angular app came from users complaining about sluggish performance after prolonged usage. Upon closer inspection, I noticed that the app was consuming more memory the longer it was used, even when users weren’t performing heavy actions like data fetching or complex computations. Clearly, there was a memory leak somewhere.

To confirm the issue, I used the Chrome DevTools memory profiler to track memory usage over time. Here’s how I approached the initial investigation:

Step 1: Using Chrome DevTools to Identify the Leak

Open Chrome DevTools: Press F12 or right-click on the page and select Inspect. Navigate to the Memory tab.

Take a Heap Snapshot: Start by taking a baseline heap snapshot to capture the current memory state. This provides a reference point to compare with later.

Simulate User Interactions: In my case, I simulated a typical user flow by navigating between different pages, opening modals, and interacting with data-heavy components.

Take Additional Snapshots: After performing these interactions, I took another heap snapshot. Then, I repeated the process a few more times to see how memory usage changed with each cycle.

What I found was clear evidence of a memory leak: memory usage kept increasing with each user interaction and wasn’t decreasing when I navigated away from the component. Objects that should have been garbage-collected were still retained in memory. Now that I had confirmed the leak, it was time to dive deeper into Angular’s specific challenges.

Analyzing the Root Causes of Memory Leaks in Angular

Angular provides an efficient way of managing components and services through its lifecycle hooks and dependency injection system, but there are still several common areas where memory leaks can occur. Some of the most frequent culprits include:

Unsubscribed Observables: Failing to unsubscribe from observables (such as those created by HttpClient or EventEmitter) can cause them to linger in memory, holding references to components or services long after they should be destroyed.

Event Listeners: Attaching event listeners (such as window or document listeners) without removing them properly can also result in memory leaks, as the DOM elements remain referenced.

Component Cleanup: Components that hold onto heavy resources or dependencies (like intervals, timeouts, or subscriptions) need to be properly cleaned up when destroyed.

With these common causes in mind, I began focusing on two areas where I suspected the leak might be happening: unhandled observables and improperly cleaned-up event listeners.

Tracking Down the Leak

In Angular, observables are often used for handling asynchronous data streams.

1. Unsubscribed Observables

In Angular, observables are often used for handling asynchronous data streams. While Angular’s HttpClient observables complete automatically, other observables like those from EventEmitter, interval, or BehaviorSubject need to be unsubscribed manually. If you don’t unsubscribe from these observables, they continue to exist in memory, even when the component that created them has been destroyed.

Problematic Code:

In one of my components, I had an observable that was used to track real-time data updates. However, I wasn’t unsubscribing from it when the component was destroyed.

export class DashboardComponent implements OnInit {
private subscription: Subscription;

ngOnInit() {
this.subscription = this.dataService.getRealTimeUpdates().subscribe(data => {
// Handle real-time data
});
}
}

Because I wasn’t unsubscribing from the observable in the ngOnDestroy() lifecycle hook, the subscription remained active even after the component was destroyed, leading to memory leakage.

The Fix:

To fix this, I added an explicit unsubscribe in the ngOnDestroy() method:

export class DashboardComponent implements OnInit, OnDestroy {
private subscription: Subscription;

ngOnInit() {
this.subscription = this.dataService.getRealTimeUpdates().subscribe(data => {
// Handle real-time data
});
}

ngOnDestroy() {
if (this.subscription) {
this.subscription.unsubscribe();
}
}
}

By unsubscribing from the observable when the component is destroyed, I ensured that the reference was released and the memory could be freed.

2. Improperly Removed Event Listeners

Another common source of memory leaks is failing to clean up event listeners. In my case, I had several components that added custom event listeners to the window object to handle resize events. While the listeners were attached correctly, I hadn’t removed them when the component was destroyed, causing the DOM elements to stay in memory.

Problematic Code:

export class ChartComponent implements OnInit {
ngOnInit() {
window.addEventListener('resize', this.handleResize);
}

handleResize() {
// Adjust chart size on window resize
}
}

The problem with this approach is that when the component is destroyed, the event listener remains attached to the window object, keeping a reference to the component and preventing the garbage collector from cleaning it up.

The Fix:

To fix this, I added logic in the ngOnDestroy() method to remove the event listener when the component is destroyed:

export class ChartComponent implements OnInit, OnDestroy {
ngOnInit() {
window.addEventListener('resize', this.handleResize);
}

handleResize() {
// Adjust chart size on window resize
}

ngOnDestroy() {
window.removeEventListener('resize', this.handleResize);
}
}

Now, the event listener is properly removed, allowing the garbage collector to reclaim the memory once the component is destroyed.

Using the takeUntil Pattern to Avoid Memory Leaks

While manually unsubscribing from observables is an effective way to prevent memory leaks, it can become tedious when you have multiple subscriptions in a single component. A cleaner and more scalable solution is to use the takeUntil pattern, which automatically unsubscribes from observables when a certain condition is met, typically the destruction of the component.

Implementing takeUntil:

  1. First, define a Subject that will act as a notifier to signal when the component is being destroyed.
  2. Use the takeUntil operator in your observables to automatically unsubscribe when the component is destroyed.
import { Subject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';

export class ProfileComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();

ngOnInit() {
this.dataService.getProfileData()
.pipe(takeUntil(this.destroy$))
.subscribe(data => {
// Handle profile data
});
}

ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
}
}

In this example, takeUntil(this.destroy$) ensures that when the component is destroyed, the observable automatically unsubscribes, preventing any memory leaks.

Verifying the Fix

After implementing these fixes, I revisited Chrome DevTools to verify that the memory leak had been resolved. I repeated the same steps as before: taking heap snapshots, simulating user interactions, and comparing memory usage over time.

This time, the results were different—memory usage remained stable, and objects were properly garbage-collected after navigating away from components. The app’s performance improved, and the sluggishness users experienced over extended usage disappeared.

Lessons Learned

Through this process, I learned several important lessons about preventing and fixing memory leaks in Angular applications:

Unsubscribe from Observables: Failing to unsubscribe from observables is one of the most common sources of memory leaks. Use takeUntil or manually unsubscribe in ngOnDestroy() to clean up observables.

Remove Event Listeners: Always clean up event listeners that are attached to global objects like window or document. Leaving them active after a component is destroyed will cause memory leaks.

Monitor Memory Usage: Regularly use tools like Chrome DevTools or other memory profiling tools to monitor your app’s memory usage, especially if you notice performance degradation over time.

Follow Angular Best Practices: Angular provides lifecycle hooks, dependency injection, and reactivity tools that are designed to help manage memory efficiently. Always follow best practices for component cleanup to avoid memory issues.

Preventing Future Memory Leaks

Now that the memory leak in my Angular app was fixed, the next step was to ensure it wouldn’t happen again. Memory leaks can sneak back into the application as new features are developed, especially in a large codebase. Therefore, it’s essential to adopt preventative measures and establish best practices to avoid similar issues in the future.

To make sure that all team members follow the best practices for managing memory in Angular,

Here are a few strategies I implemented to prevent future memory leaks:

1. Implementing Code Review Guidelines

To make sure that all team members follow the best practices for managing memory in Angular, we established a set of guidelines for code reviews. These guidelines ensure that all components and services are properly cleaned up when they’re no longer needed.

Key Guidelines:

Always Unsubscribe: Every observable created in the component must either complete on its own or be explicitly unsubscribed in ngOnDestroy(). Observables that don’t complete automatically (like those from Subject or EventEmitter) must be handled manually.

Use takeUntil for Repetitive Subscriptions: Use the takeUntil pattern whenever a component has multiple subscriptions to avoid manually unsubscribing each time. This is especially useful in components that handle multiple observables from different services.

Remove Event Listeners: Ensure that any event listeners attached to global objects are removed when the component is destroyed. If necessary, create a utility function or use Angular’s Renderer2 service to manage event listeners more systematically.

Having these guidelines in place helped streamline the code review process, as reviewers could easily check for potential memory leaks and offer suggestions before new code was merged into the main branch.

2. Automated Testing for Memory Leaks

While manual testing with tools like Chrome DevTools is effective for catching memory leaks in real-time, I wanted a more automated solution to catch leaks earlier in the development process. One way to do this is by incorporating memory leak detection into your unit tests or end-to-end tests.

Writing Unit Tests with Memory Checks

By using Jest or Karma, I added memory checks to unit tests to ensure that observables and components were properly cleaned up. These tests confirmed that memory was being released and that no lingering subscriptions or event listeners remained after the component’s lifecycle was complete.

Example unit test using Jest to check memory usage:

import { TestBed } from '@angular/core/testing';
import { DashboardComponent } from './dashboard.component';

describe('DashboardComponent', () => {
let component: DashboardComponent;

beforeEach(() => {
TestBed.configureTestingModule({
declarations: [DashboardComponent],
// Provide any necessary service mocks here
});
component = TestBed.createComponent(DashboardComponent).componentInstance;
});

it('should clean up subscriptions on destroy', () => {
component.ngOnInit();
const spy = jest.spyOn(component.subscription, 'unsubscribe');
component.ngOnDestroy();
expect(spy).toHaveBeenCalled(); // Ensure unsubscribe is called
});
});

This kind of test ensures that key components and services are properly cleaned up, helping to avoid memory issues from day one. By adding these tests to my project, I had an extra safety net to catch potential memory leaks early.

3. Leveraging Angular’s OnPush Change Detection

Angular’s default change detection strategy checks for changes in all components whenever an event occurs (such as user input or an HTTP request). This can lead to performance issues in large apps, as Angular has to check the entire component tree for updates.

To optimize performance and reduce unnecessary component re-renders (which can indirectly contribute to memory leaks), I adopted Angular’s OnPush change detection strategy for components that rely on immutable data or don’t frequently change.

How OnPush Works

With the OnPush strategy, Angular only checks for changes in a component when one of its input properties changes or when an event explicitly triggers change detection. This reduces the number of components Angular has to check on each change detection cycle, thus improving performance.

import { ChangeDetectionStrategy, Component } from '@angular/core';

@Component({
selector: 'app-user-profile',
templateUrl: './user-profile.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class UserProfileComponent {
@Input() user: User; // Will only trigger change detection if this input changes
}

When to Use OnPush:

  1. Use OnPush for components that primarily display data and don’t change frequently.
  2. Ensure that components using OnPush only depend on immutable data (i.e., their inputs should not be mutated directly).

Using OnPush in strategic areas helped reduce unnecessary change detection cycles, leading to better memory and performance management across the application.

4. Using Web Workers for Heavy Computations

In certain parts of the app, especially those dealing with large datasets or heavy calculations, performance degradation was still an issue. These tasks not only increased CPU usage but also consumed memory, as large objects and data structures were constantly being manipulated on the main thread.

To tackle this, I decided to offload these heavy computations to Web Workers, which run in the background without blocking the main UI thread.

Example of Moving a Task to a Web Worker

Here’s an example of how I moved a resource-intensive task, such as filtering large datasets, into a Web Worker:

  1. Create a Web Worker File:
// filter-worker.ts
addEventListener('message', ({ data }) => {
const filteredData = data.filter(item => item.active); // Example task
postMessage(filteredData);
});
  1. Use the Web Worker in Angular:
import { Injectable } from '@angular/core';

@Injectable({
providedIn: 'root',
})
export class DataService {
private worker: Worker;

constructor() {
if (typeof Worker !== 'undefined') {
this.worker = new Worker(new URL('./filter-worker.ts', import.meta.url));
}
}

filterData(data: any[]): Promise<any[]> {
return new Promise((resolve, reject) => {
this.worker.onmessage = ({ data }) => resolve(data);
this.worker.onerror = reject;
this.worker.postMessage(data); // Send data to Web Worker
});
}
}

By moving these heavy tasks into Web Workers, the memory footprint of my Angular app was reduced, and the overall performance improved significantly.

5. Profiling and Performance Monitoring

Memory leaks are often caught only after users report performance issues, but a proactive approach can prevent this. I integrated performance monitoring tools like Google Lighthouse and Sentry to continuously monitor the health of my application in production.

Google Lighthouse: I used Lighthouse to audit the app’s performance and memory usage, identifying areas where optimizations could be made. Lighthouse also provides recommendations on how to reduce JavaScript bloat and improve overall memory usage.

Sentry: Sentry’s performance monitoring allowed me to track real-time memory consumption, error rates, and other metrics from actual users. This gave me insight into how the app performed under different conditions, helping me identify potential memory issues before they became critical.

Conclusion

Fixing a memory leak in my Angular app was a challenging but rewarding process. It reinforced the importance of understanding how Angular’s reactivity and component lifecycle work and taught me the value of proper cleanup in observables, event listeners, and components.

Memory leaks can be difficult to track down, but by using the right tools and strategies—like takeUntil, manual unsubscribing, and careful event listener management—you can keep your Angular applications running smoothly and efficiently. If you’re facing similar performance issues in your Angular app, I hope the lessons and techniques shared here will help you track down and fix memory leaks in your own projects.

Read Next: