Docs
/
Angular
Chapter 15

15 — Performance Optimization

Performance Checklist

TechniqueImpactEffort
OnPush change detection★★★★★Low
Lazy loading routes★★★★★Low
track in @for★★★★Low
@defer for heavy components★★★★Low
Signals over methods in templates★★★★Medium
Virtual scrolling for long lists★★★★Medium
SSR + Hydration★★★★Medium
Tree shaking unused code★★★Low
Image optimization (NgOptimizedImage)★★★Low
Bundle analysis + code splitting★★★Medium

Lazy Loading Routes

// ✅ Each route loads its own JavaScript chunk
export const routes: Routes = [
  {
    path: 'dashboard',
    loadComponent: () => import('./features/dashboard/dashboard.component')
      .then(m => m.DashboardComponent),
  },
  {
    path: 'admin',
    loadChildren: () => import('./features/admin/admin.routes')
      .then(m => m.ADMIN_ROUTES),
    canActivate: [authGuard],
  },
];

// Preload strategy — load lazy routes in background after initial load
provideRouter(routes, withPreloading(PreloadAllModules))

@defer — Lazy Load Template Sections

<!-- Load heavy chart only when visible -->
@defer (on viewport) {
  <app-analytics-dashboard />
} @placeholder {
  <div class="skeleton" style="height: 400px"></div>
}

<!-- Load after user interaction -->
@defer (on interaction) {
  <app-comment-section [postId]="post.id" />
} @placeholder {
  <button>Load Comments</button>
}

<!-- Load when browser is idle -->
@defer (on idle) {
  <app-recommendations />
}

<!-- Prefetch when idle, render on viewport -->
@defer (on viewport; prefetch on idle) {
  <app-footer />
}

Image Optimization — NgOptimizedImage

import { NgOptimizedImage } from '@angular/common';

@Component({
  imports: [NgOptimizedImage],
  template: `
    <!-- LCP image — mark as priority -->
    <img ngSrc="/assets/hero.jpg" width="1200" height="600" priority>

    <!-- Regular image — lazy loaded by default -->
    <img ngSrc="/assets/thumbnail.jpg" width="300" height="200">

    <!-- With image loader (CDN) -->
    <img ngSrc="hero.jpg" width="1200" height="600" priority>
  `,
})
// app.config.ts — configure image loader for CDN
import { provideImgixLoader } from '@angular/common';

providers: [
  provideImgixLoader('https://my-cdn.imgix.net/'),
]

NgOptimizedImage automatically:

  • Sets loading="lazy" (except priority images)
  • Sets fetchpriority="high" for priority images
  • Generates srcset for responsive images
  • Warns about missing width/height (prevents layout shift)

Virtual Scrolling

import { ScrollingModule } from '@angular/cdk/scrolling';

@Component({
  imports: [ScrollingModule],
  template: `
    <cdk-virtual-scroll-viewport itemSize="72" class="list">
      <app-user-card
        *cdkVirtualFor="let user of users; trackBy: trackById"
        [user]="user"
      />
    </cdk-virtual-scroll-viewport>
  `,
  styles: [`.list { height: 80vh; }`],
})
export class UserListComponent {
  users: User[] = [];  // 10,000+ items — only visible ones are rendered
}

Bundle Analysis

# Build with stats
ng build --stats-json

# Analyze with webpack-bundle-analyzer
npx webpack-bundle-analyzer dist/my-app/stats.json

# Or use source-map-explorer
npx source-map-explorer dist/my-app/browser/*.js

Common Bundle Issues

ProblemFix
Large vendor.jsLazy load features, remove unused imports
moment.js in bundleReplace with date-fns or dayjs
Full lodash importedUse lodash-es with tree shaking or individual imports
Icons loaded eagerlyLazy load icon sets
Unused Material modulesImport only what you use

Tree Shaking

// ❌ Imports entire library
import * as _ from 'lodash';

// ✅ Tree-shakable — only imports what's used
import { debounce } from 'lodash-es';

// ❌ Barrel file re-exports everything
export * from './components';

// ✅ Direct imports
import { ButtonComponent } from './components/button.component';

SSR + Hydration (Angular 17+)

ng add @angular/ssr
// app.config.server.ts
import { provideServerRendering } from '@angular/platform-server';
import { provideClientHydration, withEventReplay } from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {
  providers: [
    provideServerRendering(),
    provideClientHydration(withEventReplay()),   // Replay events during hydration
  ],
};

Benefits:

  • Faster FCP — HTML rendered on server
  • SEO — Crawlers see full content
  • Hydration — Angular reuses server-rendered DOM (no re-render flicker)

Runtime Performance Tips

Avoid Methods in Templates

<!-- ❌ Called on EVERY change detection cycle -->
<p>{{ getFullName() }}</p>
<div [class.active]="isActive()"></div>

<!-- ✅ Use computed signal -->
<p>{{ fullName() }}</p>

<!-- ✅ Or pure pipe -->
<p>{{ user | fullName }}</p>

Detach Rarely-Changing Components

@Component({ changeDetection: ChangeDetectionStrategy.OnPush })
export class StaticWidgetComponent {
  private cdr = inject(ChangeDetectorRef);

  ngAfterViewInit() {
    this.cdr.detach();   // No more change detection
  }
}

Use OnPush Everywhere

// angular.json — set OnPush as default for generated components
"schematics": {
  "@schematics/angular:component": {
    "changeDetection": "OnPush",
    "standalone": true
  }
}

Build Optimizations

// angular.json — production config
"configurations": {
  "production": {
    "budgets": [
      { "type": "initial", "maximumWarning": "500kB", "maximumError": "1MB" },
      { "type": "anyComponentStyle", "maximumWarning": "4kB" }
    ],
    "outputHashing": "all",
    "optimization": true,
    "sourceMap": false,
    "extractLicenses": true
  }
}
# Check bundle sizes
ng build --configuration=production
# Look at dist/ folder sizes

Key Takeaways

  • OnPush + signals = the foundation of Angular performance
  • Lazy load everything: routes (loadComponent), templates (@defer), images (NgOptimizedImage)
  • Use cdk-virtual-scroll-viewport for lists over ~100 items
  • Never call functions in templates — use computed() or pure pipes
  • Run source-map-explorer to find bundle bloat
  • SSR + hydration gives faster first paint and SEO benefits
  • Set bundle budgets in angular.json to catch size regressions in CI