How to Optimize Performance in Angular Applications
How to Optimize Performance in Angular Applications
Building Angular apps that feel fast and responsive is one of the most satisfying parts of web development. You click a button and something happens instantly. You scroll through a long list and it doesn’t stutter. But as your application grows, you might notice it starts to feel sluggish. Buttons take a moment to respond, lists flicker when you update them, and the initial load time creeps up. These are performance issues, and they’re completely normal as your app gets more complex.
The good news? Angular gives you powerful tools to keep your app running smoothly. You don’t need to be an expert to make a big difference. This guide will walk you through three simple techniques that can dramatically improve your app’s speed. We’ll focus on two core ideas: reducing unnecessary work and loading only what you need, when you need it.
Understanding the Performance Challenge
Before we dive into solutions, let’s understand what makes Angular apps slow in the first place. Angular’s magic comes from its ability to automatically update the screen when your data changes. This is called change detection. When you click a button, fetch data, or type in a form, Angular checks every part of your app to see if anything on the screen needs updating.
Imagine you have a to-do list app with 100 tasks. When you mark one task as complete, Angular’s default behavior is to check all 100 tasks to see if anything changed. That’s like searching your entire house for your keys when you know they’re in your pocket. It’s reliable, but it’s overkill.
For small apps, this isn’t a problem. But as you add more components, more lists, and more complex features, these checks add up. Your app spends more time checking for changes than actually making them. The techniques in this guide help Angular work smarter, not harder.
Step-by-Step Optimization Guide
Let’s walk through three practical steps you can implement today. Each one builds on the last, and you can use them independently or together.
Step 1: Use OnPush Change Detection
The easiest win is to tell Angular: “Only check this component when its inputs change.” This is called the OnPush change detection strategy. By default, Angular uses the Default strategy, which checks components on every single change detection cycle. Switching to OnPush can cut your change detection work by 80% or more.
Here’s how to implement it:
import { Component, ChangeDetectionStrategy, Input } from '@angular/core';
@Component({
selector: 'app-user-card',
templateUrl: './user-card.component.html',
styleUrls: ['./user-card.component.css'],
changeDetection: ChangeDetectionStrategy.OnPush // Add this line
})
export class UserCardComponent {
@Input() user: { name: string; email: string } | null = null;
get initials(): string {
if (!this.user?.name) return '';
return this.user.name.split(' ').map(n => n[0]).join('').toUpperCase();
}
}
What this code does:
- We import
ChangeDetectionStrategyfrom Angular’s core library - We add the
changeDetectionproperty to the@Componentdecorator - We set it to
ChangeDetectionStrategy.OnPush - The
@Input() usermeans this component receives a user object from its parent
Plain language explanation: This tells Angular, “Hey, only bother checking this component for updates if the user input actually changes.” If the parent component updates other data, this UserCardComponent gets skipped entirely. It’s like telling your brain to only think about your keys when you actually hear them jingle, not every time you walk through a doorway.
When to use it: Apply OnPush to any component that only depends on its @Input() properties. Presentation components like cards, lists, and forms are perfect candidates. Be more careful with components that manage a lot of internal state or use services extensively.
Step 2: Add TrackBy for Lists
Have you ever noticed a list flickering or completely re-rendering when you add a single item? That’s because Angular doesn’t know which items are new, moved, or unchanged. By default, when you use *ngFor to loop through an array, Angular tracks items by their object references. If the array reference changes, Angular destroys and recreates every DOM element in the list. This is slow and creates a janky user experience.
trackBy solves this by giving Angular a unique identifier for each item. Instead of rebuilding the entire list, Angular only adds, removes, or moves the items that actually changed.
Here’s a complete example:
// In your component TypeScript file
import { Component } from '@angular/core';
@Component({
selector: 'app-task-list',
templateUrl: './task-list.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class TaskListComponent {
tasks = [
{ id: '1', title: 'Buy groceries', completed: false },
{ id: '2', title: 'Walk the dog', completed: true },
{ id: '3', title: 'Finish report', completed: false }
];
// This function tells Angular how to identify each task
trackByTaskId(index: number, task: { id: string; title: string; completed: boolean }): string {
return task.id; // Return the unique identifier
}
addTask() {
const newTask = {
id: Date.now().toString(), // Simple unique ID
title: 'New task',
completed: false
};
this.tasks = [...this.tasks, newTask]; // Create new array reference
}
}
<!-- In your component template -->
<div class="task-list">
<button (click)="addTask()">Add Task</button>
<ul>
<li *ngFor="let task of tasks; trackBy: trackByTaskId">
<input type="checkbox" [checked]="task.completed" />
{{ task.title }}
</li>
</ul>
</div>
What this code does:
- The
trackByTaskIdfunction takes the current index and task object - It returns the task’s
idproperty as a unique identifier - In the template, we add
trackBy: trackByTaskIdto the*ngFordirective - When we add a task, we create a new array (which triggers change detection) but Angular only creates one new
<li>element instead of rebuilding all of them
Plain language explanation: Think of trackBy like giving each list item a name tag at a party. Without name tags, if someone new arrives, you’d have to ask everyone’s name again. With name tags, you just greet the new person and move on. Angular does the same thing—it remembers each item by its ID and only deals with the ones that actually changed.
Pro tip: Always use trackBy for lists that can change, especially long lists. The performance difference is dramatic with 50+ items.
Step 3: Lazy-Load Feature Modules
The first two techniques made Angular’s runtime faster. Now let’s make your app load faster. When users first visit your app, they download the entire application—even pages they might never visit. Lazy loading changes this by splitting your app into smaller chunks and only loading them when needed.
Imagine you have an admin dashboard that only 5% of users ever see. Why make everyone download that code? With lazy loading, the admin module sits on the server until someone actually navigates to /admin. Then Angular fetches it in the background.
Here’s how to set it up:
// In your app-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { HomeComponent } from './home/home.component';
const routes: Routes = [
{
path: '',
component: HomeComponent
},
{
path: 'admin',
// This line tells Angular to load the admin module only when needed
loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule)
},
{
path: 'reports',
loadChildren: () => import('./reports/reports.module').then(m => m.ReportsModule)
}
];
@NgModule({
imports: [RouterModule.forRoot(routes)],
exports: [RouterModule]
})
export class AppRoutingModule { }
// In your admin/admin-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AdminDashboardComponent } from './admin-dashboard.component';
const routes: Routes = [
{
path: '',
component: AdminDashboardComponent
}
];
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule]
})
export class AdminRoutingModule { }
What this code does:
- In the main routing file, instead of importing the
AdminModuledirectly, we use a dynamicimport()statement inside a function - This function returns a promise that loads the module only when the route is activated
- The
loadChildrenproperty tells the router “don’t load this yet, just remember how to load it later” - We create a separate routing module for each feature that defines its own routes
Plain language explanation: This is like ordering food at a restaurant. Instead of bringing every dish to your table when you sit down (eager loading), the kitchen waits for you to order each dish (lazy loading). Your initial “bundle” is much smaller, so your app loads faster. When you click a link to the admin area, Angular says “Oh, I need that module!” and fetches it just in time.
How to verify it’s working: Open your browser’s DevTools, go to the Network tab, and look for JavaScript files being loaded when you navigate to different routes. You should see new chunks appear when you first visit /admin or /reports.
Common Mistakes
Even with the best techniques, it’s easy to fall into these traps. Here’s what to watch out for:
1. Forgetting to update references with OnPush: When you use OnPush, Angular only checks inputs that have new object references. If you mutate an object instead of creating a new one, Angular won’t detect the change.
// ❌ Wrong - mutates existing object
this.user.name = 'New Name'; // OnPush component won't update
// ✅ Correct - creates new object
this.user = { ...this.user, name: 'New Name' }; // OnPush component updates
2. Using index as trackBy: It’s tempting to use the array index as a unique identifier, but this defeats the purpose. If items reorder, Angular will get confused.
// ❌ Wrong - index changes when items move
trackByIndex(index: number): number {
return index;
}
// ✅ Correct - use a stable ID
trackByItemId(index: number, item: any): string {
return item.id;
}
3. Over-optimizing too early: Don’t apply these techniques everywhere from day one. Start with the components that cause actual performance problems. Premature optimization makes your code harder to maintain without real benefits.
4. Not measuring first: You can’t improve what you don’t measure. Always profile your app before and after making changes. Sometimes the bottleneck is in an unexpected place.
5. Forgetting to test after lazy loading: When you split modules, you might accidentally create multiple instances of services. Make sure services that should be singletons are provided in the root module, not in lazy-loaded modules.
Troubleshooting
When things don’t work as expected, here’s how to get back on track:
Problem: My OnPush component doesn’t update when data changes
- Actionable tip: Check that you’re creating new object references, not mutating existing ones. Use the spread operator (
{ ...obj }) or immutable patterns. Add aconsole.log()in your component to verify it’s receiving new inputs. - Quick test: Temporarily remove
OnPush. If the component updates, you have a reference mutation issue.
Problem: List still flickers after adding trackBy
- Actionable tip: Open Angular DevTools and inspect the component. Verify that your
trackByfunction is actually attached to the*ngFordirective. Check that the returned ID is truly unique and stable. - Quick test: Add a
console.log()inside yourtrackByfunction. It should only log when items are added, removed, or have their IDs changed—not on every render.
Problem: Lazy-loaded routes throw “Module not found” errors
- Actionable tip: Verify the path in your
import()statement is correct. The path is relative to the file where you’re writing the import. Check for typos in the module class name. - Quick test: Try a regular static import to see if the path resolves. If it works statically but fails with dynamic import, you likely have a circular dependency.
Problem: Initial load is still slow after lazy loading
- Actionable tip: Check your main bundle size in the build output. Run
ng build --stats-jsonand analyze it withwebpack-bundle-analyzer. You might have large libraries in your main module that should be lazy-loaded too. - Quick test: Look at the Network tab in DevTools. Are you still loading one huge JavaScript file? If so, your lazy loading configuration isn’t working.
Problem: Everything feels slow but I don’t know why
- Actionable tip: Install the Angular DevTools browser extension. Use the profiler to record user interactions. Look for components that take more than 16ms to render—that’s your frame budget for smooth 60fps animations.
- Quick test: Add
ng.profiler.timeChangeDetection()to your browser console. This will log how long each change detection cycle takes. If it’s consistently over 50ms, you need optimization.
Practice Exercises
The best way to learn is by doing. Try these exercises to solidify your understanding:
Exercise 1: Build a Performance-Optimized Contact List
- Create a new Angular component called
ContactListComponent - Display a list of 100 contacts with names and emails
- Implement
OnPushchange detection - Add a
trackByfunction using contact IDs - Add a button that randomly updates one contact’s email
- Verify that only that one list item re-renders (use Angular DevTools to confirm)
Exercise 2: Lazy-Load a Settings Page
- Generate a new feature module:
ng generate module settings --routing - Create a
SettingsComponentinside this module - Add a route to lazy-load this module at
/settings - Build your app and check the output—confirm you see a separate chunk for the settings module
- Open the Network tab and verify the settings chunk only loads when you navigate to
/settings
Exercise 3: Create a Reusable TrackBy Factory
- Write a function that generates trackBy functions for any property name:
export function createTrackBy<T>(property: keyof T): (index: number, item: T) => any {
return (index: number, item: T) => item[property];
}
- Use it in a component:
trackById = createTrackBy<Task>('id'); - Apply this pattern to three different lists in your app
- Notice how this reduces code duplication while keeping type safety
Exercise 4: Performance Toggle Switch
- Build a component that displays a large dataset
- Add a button that toggles between
DefaultandOnPushstrategies - Measure the change detection time in both modes using
ng.profiler.timeChangeDetection() - Document the performance difference in a comment
- This helps you understand the real impact of your optimizations
Next Steps
You’ve learned three powerful techniques, but performance optimization is a journey. Here’s where to go from here:
Dive deeper into change detection: Learn about ChangeDetectorRef and how to manually trigger change detection when you need to. This gives you fine-grained control in complex scenarios.
Explore advanced bundling: Look into Angular’s esbuild builder and how to configure custom webpack builds for even more aggressive code splitting. The ngx-build-plus package lets you customize the build process.
Master state management: As apps grow, consider using NgRx or NgXs. These libraries are designed to work seamlessly with OnPush and make state changes more predictable and easier to optimize.
Set up performance budgets: In your angular.json, configure budgets to fail builds if your bundles exceed certain sizes. This catches performance regressions before they reach production.
"budgets": [
{
"type": "initial",
"maximumWarning": "500kb",
"maximumError": "1mb"
}
]
Monitor production performance: Use tools like Google Lighthouse, Web Vitals, or Sentry to track real user performance. Lab tests are useful, but real-world data tells the full story.
Join the community: Follow Angular’s official blog and the #angular-performance hashtag on Twitter. The community regularly shares new techniques and best practices.
Summary
Optimizing Angular performance doesn’t require rewriting your entire app. By focusing on three core techniques—OnPush change detection, trackBy for lists, and lazy loading modules—you can make dramatic improvements with minimal code changes.
Remember the key principles:
- Work smarter, not harder:
OnPushtells Angular to skip unnecessary checks - Be explicit about identity:
trackByhelps Angular understand what changed - Load only what you need: Lazy loading splits your app into manageable chunks
Start small. Pick one component that’s causing performance issues and apply OnPush. Measure the improvement. Then move to your biggest list and add trackBy. Finally, identify a feature that most users don’t need and lazy-load it. Each step will make your app feel snappier and more professional.
Performance optimization is a skill that grows with practice. The exercises in this guide give you a safe space to experiment. The troubleshooting section arms you with tools to diagnose issues. And the next steps point you toward deeper mastery.
Your users will notice the difference. And you’ll have the satisfaction of knowing your Angular app isn’t just feature-rich—it’s fast, efficient, and a joy to use.