Angular Performance Optimization Lazy Loading OnPush Bundle Size
Md Sanwar Hossain - Software Engineer
Md Sanwar Hossain

Software Engineer · Angular · Spring Boot · Full-Stack

Angular April 2, 2026 18 min read Angular Series

Angular Performance Optimization: Lazy Loading, OnPush & Bundle Size

Angular applications can suffer from poor performance for two main reasons: large JavaScript bundles that delay initial load, and excessive change detection cycles that slow runtime rendering. Both are solvable with the right patterns — lazy loading defers code until it's needed, OnPush change detection makes Angular smarter about when to re-render, and esbuild makes your builds dramatically faster.

Table of Contents

  1. Why Bundle Size and Change Detection Matter
  2. Route-Level Lazy Loading with loadComponent
  3. OnPush Change Detection Strategy
  4. TrackBy Functions and NgFor Optimization
  5. Angular esbuild Builder and Build Optimization
  6. Preloading Strategies
  7. Bundle Analysis with source-map-explorer

Why Bundle Size and Change Detection Matter

Angular Lazy Loading Modules Architecture | mdsanwarhossain.me
Angular Lazy Loading Modules Architecture — mdsanwarhossain.me

Every millisecond of JavaScript parse time costs user experience. Google's research shows that 53% of mobile users abandon sites that take more than 3 seconds to load. For Angular, the main bundle — including the framework, your feature code, and all eagerly loaded dependencies — is parsed and executed before the app becomes interactive.

A typical Angular enterprise app without optimization can weigh 2–5 MB of JavaScript. Even on a fast connection that means 200–500ms of parse/compile time before a single pixel renders. On a mid-range Android device, JavaScript execution is 4–5x slower than a MacBook Pro benchmark, pushing that to 1–2 seconds of blocking main thread time.

Change detection adds runtime cost. Angular's default change detection runs on every browser event: mouse clicks, keyboard input, timer callbacks, HTTP responses, WebSocket messages. In a component tree with 300 components, every click event can trigger 300 component checks. At 60 frames per second, that budget is only 16ms per frame — one excessive change detection cycle can drop frames and cause jank.

Route-Level Lazy Loading with loadComponent

Angular's router supports lazy loading at the route level without NgModules. Use loadComponent for single components and loadChildren for groups of routes defined in a separate file. The Angular build system automatically code-splits these into separate chunks downloaded only when the route is first navigated to.

// app.routes.ts
import { Routes } from '@angular/router';
import { authGuard } from './core/guards/auth.guard';

export const routes: Routes = [
  {
    path: '',
    loadComponent: () =>
      import('./features/home/home.component').then(m => m.HomeComponent),
  },
  {
    path: 'dashboard',
    canActivate: [authGuard],
    loadComponent: () =>
      import('./features/dashboard/dashboard.component')
        .then(m => m.DashboardComponent),
  },
  {
    path: 'admin',
    canActivate: [authGuard],
    // Entire admin feature loads as one chunk
    loadChildren: () =>
      import('./features/admin/admin.routes').then(m => m.adminRoutes),
  },
  {
    path: 'reports',
    loadChildren: () =>
      import('./features/reports/reports.routes').then(m => m.reportsRoutes),
  },
];

// features/admin/admin.routes.ts
export const adminRoutes: Routes = [
  {
    path: '',
    loadComponent: () =>
      import('./admin-shell.component').then(m => m.AdminShellComponent),
    children: [
      {
        path: 'users',
        loadComponent: () =>
          import('./users/users.component').then(m => m.UsersComponent),
      },
      {
        path: 'settings',
        loadComponent: () =>
          import('./settings/settings.component').then(m => m.SettingsComponent),
      },
    ],
  },
];

Each dynamic import() creates a separate webpack/esbuild chunk. The key insight: the code for AdminShellComponent, UsersComponent, and all their dependencies (including Angular Material modules they import) is excluded from the initial bundle and only fetched when the user navigates to /admin.

OnPush Change Detection Strategy

Angular Performance Optimization Strategies | mdsanwarhossain.me
Angular Performance Optimization Strategies — mdsanwarhossain.me

ChangeDetectionStrategy.OnPush tells Angular to skip change detection for a component unless one of three conditions is met: an @Input() reference changes, an event originates from the component or its children, or the component explicitly marks itself dirty with ChangeDetectorRef.markForCheck(). With signals, Angular extends this further — a signal-based component with OnPush only re-renders when a signal it reads actually changes value.

// product-list.component.ts — optimal OnPush + Signals pattern
import {
  Component, ChangeDetectionStrategy, signal,
  computed, inject, OnInit
} from '@angular/core';
import { ProductService } from '../product.service';
import { ProductCardComponent } from './product-card/product-card.component';
import { FormsModule } from '@angular/forms';

@Component({
  selector: 'app-product-list',
  standalone: true,
  imports: [ProductCardComponent, FormsModule],
  changeDetection: ChangeDetectionStrategy.OnPush,
  template: `
    <input [(ngModel)]="searchTerm" placeholder="Search...">
    <p>Showing {{ filteredProducts().length }} of {{ products().length }}</p>
    @for (product of filteredProducts(); track product.id) {
      <app-product-card [product]="product" />
    }
  `
})
export class ProductListComponent implements OnInit {
  private productService = inject(ProductService);

  products = signal<Product[]>([]);
  searchTerm = signal('');

  filteredProducts = computed(() => {
    const term = this.searchTerm().toLowerCase();
    return this.products().filter(p =>
      p.name.toLowerCase().includes(term)
    );
  });

  ngOnInit() {
    this.productService.getAll().subscribe(p => this.products.set(p));
  }
}

Notice that filteredProducts is a computed signal derived from both products and searchTerm. Angular only re-renders the template when either of those signals changes — not on every keystroke in unrelated inputs elsewhere in the app. With 300 components all using OnPush and signals, a signal change in one component will not trigger checking the other 299.

TrackBy Functions and NgFor Optimization

When Angular re-renders a list, it needs to determine which DOM nodes to reuse and which to recreate. Without a tracking identity, Angular destroys and recreates all DOM nodes on every list update — even if only one item changed. The track expression in the new @for syntax (or trackBy with *ngFor) provides the identity key Angular uses to map existing DOM nodes to updated list items.

// ✅ Modern @for with track (Angular 17+)
@for (user of users(); track user.id) {
  <app-user-card [user]="user" />
}

// ✅ *ngFor with trackBy for older codebases
<app-user-card
  *ngFor="let user of users; trackBy: trackById"
  [user]="user"
/>

// In component class:
trackById(index: number, user: User): number {
  return user.id;  // stable identity — same id = reuse DOM node
}

// ❌ Never use index as track for mutable lists
@for (user of users(); track $index) { /* BAD — causes full re-render */ }

// ✅ For lists that never reorder, $index is fine:
@for (step of steps(); track $index) {
  <li>{{ step }}</li>
}

The performance impact compounds with list size. A table with 500 rows updating one item: without track, Angular destroys and creates 500 <tr> elements; with track user.id, Angular touches only the one changed row. For virtualized lists (Angular CDK Viewport), proper tracking is essential for the virtual scroll to correctly maintain component state.

Angular esbuild Builder and Build Optimization

Angular 16 introduced the esbuild-based build system as opt-in; it became the default in Angular 17. esbuild is 10–100x faster than Webpack for cold builds and dramatically faster for incremental rebuilds during development. Switching is a one-line change in angular.json:

// angular.json — switch to esbuild (already default in Angular 17+)
{
  "projects": {
    "my-app": {
      "architect": {
        "build": {
          "builder": "@angular-devkit/build-angular:application",
          "options": {
            "outputPath": "dist/my-app",
            "index": "src/index.html",
            "browser": "src/main.ts",
            "polyfills": ["zone.js"],
            "tsConfig": "tsconfig.app.json",
            "assets": ["src/favicon.ico", "src/assets"],
            "styles": ["src/styles.scss"],
            "scripts": [],
            "budgets": [
              {
                "type": "initial",
                "maximumWarning": "500kB",
                "maximumError": "1MB"
              },
              {
                "type": "anyComponentStyle",
                "maximumWarning": "4kB",
                "maximumError": "8kB"
              }
            ]
          }
        }
      }
    }
  }
}

The budgets configuration enforces bundle size limits at build time. Set maximumWarning at your target and maximumError at the absolute limit — the build fails if a chunk exceeds the error threshold, preventing accidental bundle bloat from reaching production.

Additional esbuild optimizations to enable for production:

// Additional build options for maximum optimization
{
  "configurations": {
    "production": {
      "optimization": true,
      "outputHashing": "all",
      "sourceMap": false,
      "namedChunks": false,
      "aot": true,
      "buildOptimizer": true,
      "serviceWorker": false,
      "extractLicenses": true
    }
  }
}

// package.json scripts for build analysis
{
  "scripts": {
    "build:prod": "ng build --configuration production",
    "build:stats": "ng build --stats-json",
    "analyze": "npx source-map-explorer dist/my-app/browser/*.js"
  }
}

Preloading Strategies

Lazy loading splits bundles but creates a delay when the user first navigates to a route — the browser must fetch, parse, and execute the chunk before the route renders. Preloading strategies solve this by downloading lazy chunks in the background after the initial page load, so they're already cached when needed.

// Custom preloading strategy — preload only marked routes
import { PreloadingStrategy, Route } from '@angular/router';
import { Observable, of } from 'rxjs';

export class SelectivePreloadingStrategy implements PreloadingStrategy {
  preload(route: Route, load: () => Observable<unknown>): Observable<unknown> {
    return route.data?.['preload'] === true ? load() : of(null);
  }
}

// Mark routes to preload
export const routes: Routes = [
  {
    path: 'dashboard',
    data: { preload: true },  // preload this chunk
    loadComponent: () => import('./dashboard/dashboard.component')
      .then(m => m.DashboardComponent),
  },
  {
    path: 'reports',
    // data.preload not set — only loaded on navigation
    loadChildren: () => import('./reports/reports.routes')
      .then(m => m.reportsRoutes),
  },
];

// Register in app.config.ts
provideRouter(
  routes,
  withPreloading(SelectivePreloadingStrategy)
)

Use PreloadAllModules for small apps where all routes are likely to be visited. Use a custom strategy for large apps where you want control — typically preload the features most users navigate to immediately after login (dashboard, profile) but defer admin and rare features.

Bundle Analysis with source-map-explorer

You cannot optimize what you cannot measure. source-map-explorer generates an interactive treemap showing exactly which modules contribute to each bundle — invaluable for finding unexpected large dependencies like moment.js (330KB), lodash (full bundle instead of individual methods), or duplicate Angular Material imports.

# Install source-map-explorer
npm install -g source-map-explorer

# Build with source maps enabled (temporarily, for analysis only)
ng build --configuration production --source-map

# Analyze the main bundle
npx source-map-explorer 'dist/my-app/browser/main*.js'

# Analyze all chunks including lazy routes
npx source-map-explorer 'dist/my-app/browser/*.js'

# Alternative: webpack-bundle-analyzer (if using webpack)
npm install -g webpack-bundle-analyzer
ng build --stats-json
npx webpack-bundle-analyzer dist/my-app/browser/stats.json

Common findings and fixes from bundle analysis:

Key Takeaways

Leave a Comment

Related Posts

Md Sanwar Hossain - Software Engineer
Md Sanwar Hossain

Software Engineer · Angular · Spring Boot · Full-Stack

Last updated: April 2, 2026