Angular
·5 min read·↗

Maximizing Performance with Lazy Loading and Preload Routing in Angular

Main cover illustration for article: Maximizing Performance with Lazy Loading and Preload Routing in Angular
Summarize with AI:

⚡ Using Modern Angular (v17+)? Jump directly to Modern Lazy Loading with Standalone Components & @defer to see loadComponent, provideRouter(), and @defer views in action.

When building an enterprise Angular application with multiple features, bundling all components into a single main.js bundle hurts your initial load time. Users end up waiting for screens and features they might never even visit during their session.

To deliver great Core Web Vitals and snappy user experiences, we must split our application into on-demand chunks using Lazy Loading and Preload Strategies.

In this guide, I will show you how lazy loading works in both legacy NgModule architectures and modern Standalone applications, how to configure custom preloading strategies, and how to combine route splitting with template-level @defer views.


The Scenario: A Multi-Feature Payment App

Imagine we are building a fintech dashboard with four distinct feature areas:

  1. Dashboard (main landing page)
  2. MoneyTransfer
  3. Wallet
  4. Activity

Let's look at what happens when all features are bundled together versus lazy-loaded.


1. Legacy Approach: NgModule Lazy Loading & Preloading

In older versions of Angular (v2 – v16), lazy loading required a dedicated Routing.module for each feature:

// app-routing.module.ts (Legacy NgModule)
import { NgModule } from '@angular/core';
import { RouterModule, Routes, PreloadAllModules } from '@angular/router';
 
const routes: Routes = [
  { path: '', redirectTo: '/dashboard', pathMatch: 'full' },
  {
    path: 'wallet',
    loadChildren: () => import('./wallet/wallet.module').then((m) => m.WalletModule),
  },
  {
    path: 'activity',
    loadChildren: () => import('./activity/activity.module').then((m) => m.ActivityModule),
  },
  {
    path: 'dashboard',
    loadChildren: () => import('./dashboard/dashboard.module').then((m) => m.DashboardModule),
  },
  {
    path: 'money',
    loadChildren: () => import('./moneytransfer/moneytransfer.module').then((m) => m.MoneyTransferModule),
  },
];
 
@NgModule({
  imports: [
    RouterModule.forRoot(routes, {
      preloadingStrategy: PreloadAllModules, // Preloads all modules in the background
    }),
  ],
  exports: [RouterModule],
})
export class AppRoutingModule {}

While this reduced the initial main.js bundle size, creating intermediate routing modules for every single screen created unnecessary boilerplate.


Modern Lazy Loading with Standalone Components & @defer

In modern Angular (v17+ / v19), we configure routing cleanly without NgModule using provideRouter() and loadComponent:

1. Route-Level Lazy Loading with loadComponent

// app.routes.ts (Modern Standalone Routes)
import { Routes } from '@angular/router';
 
export const routes: Routes = [
  { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
  {
    path: 'dashboard',
    loadComponent: () =>
      import('./dashboard/dashboard.component').then((m) => m.DashboardComponent),
  },
  {
    path: 'wallet',
    loadComponent: () =>
      import('./wallet/wallet.component').then((m) => m.WalletComponent),
    data: { preload: true }, // Route flag for custom preloading
  },
  {
    path: 'money',
    loadComponent: () =>
      import('./money/money.component').then((m) => m.MoneyComponent),
  },
  {
    path: 'activity',
    loadChildren: () =>
      import('./activity/activity.routes').then((m) => m.ACTIVITY_ROUTES),
  },
];

2. Application Config with provideRouter

In app.config.ts, we initialize the router with modern features:

// app.config.ts
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
import { routes } from './app.routes';
 
export const appConfig: ApplicationConfig = {
  providers: [
    provideZoneChangeDetection({ eventCoalescing: true }),
    provideRouter(
      routes,
      withPreloading(PreloadAllModules) // Native preloading helper
    ),
  ],
};

Building a Custom Preload Strategy

PreloadAllModules downloads every lazy chunk immediately after the initial page renders. In large applications, this can waste mobile bandwidth.

A Custom Preload Strategy lets you selectively preload only high-priority routes (for example, routes marked with data: { preload: true }):

// custom-preloading.strategy.ts
import { Injectable } from '@angular/core';
import { PreloadingStrategy, Route } from '@angular/router';
import { Observable, of } from 'rxjs';
 
@Injectable({
  providedIn: 'root',
})
export class SelectivePreloadingStrategy implements PreloadingStrategy {
  preload(route: Route, load: () => Observable<any>): Observable<any> {
    // Check if the route has the preload flag enabled
    if (route.data && route.data['preload']) {
      return load();
    }
    return of(null);
  }
}

Registering the Strategy in app.config.ts:

// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withPreloading } from '@angular/router';
import { routes } from './app.routes';
import { SelectivePreloadingStrategy } from './custom-preloading.strategy';
 
export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(
      routes,
      withPreloading(SelectivePreloadingStrategy)
    ),
  ],
};

Granular Lazy Loading with @defer Blocks

Route-level lazy loading splits full pages. But what about heavy widgets inside a page, like transaction charts or PDF export tools?

Modern Angular provides Deferrable Views (@defer) to lazy-load individual components right from your templates:

<!-- dashboard.component.html -->
<div class="dashboard-container">
  <h1>Welcome to your Dashboard</h1>
 
  <!-- Immediate critical content -->
  <app-account-summary />
 
  <!-- Lazy-loaded below the fold when scrolled into view -->
  @defer (on viewport) {
    <app-heavy-spending-chart [transactions]="transactions()" />
  } @placeholder {
    <div class="chart-skeleton">Loading spending analytics...</div>
  } @loading (minimum 300ms) {
    <div class="spinner">Fetching chart assets...</div>
  } @error {
    <p>Failed to load analytics chart.</p>
  }
</div>

Why @defer is superior for component-level code splitting:

  1. Zero router configuration required: Angular build automatically generates a separate JS chunk for <app-heavy-spending-chart>.
  2. Interactive triggers: Load on viewport, interaction (click/focus), hover, timer(2s), or idle.
  3. Built-in UX states: Native @placeholder, @loading, and @error blocks prevent layout shifting.

Strategy Comparison Matrix

StrategyWhen to UseBenefit
loadComponentSeparate application views and pagesReduces initial main.js bundle size
PreloadAllModulesSmall-to-medium apps with stable networkInstant page transitions after initial load
SelectivePreloadingStrategyLarge enterprise apps with restricted mobile bandwidthPreloads only high-conversion or frequent routes
@defer (on viewport)Heavy widgets, graphs, modals, and rich editorsDefers JavaScript download until component is actually visible

Summary

Optimizing performance in modern Angular requires a multi-layered approach:

  1. Use loadComponent in your route definitions to split pages into Standalone chunks.
  2. Configure withPreloading(SelectivePreloadingStrategy) in provideRouter() to preload high-value routes in the background.
  3. Leverage @defer (on viewport) inside templates to defer heavy third-party components and widgets until the user needs them.
Part of the Angular Series

These are my experiences learning and facing my daily challenges working with Angular.

View Entire Series

Frequently Asked Questions

What is the difference between loadChildren and loadComponent in Angular routing?

loadChildren is used to lazy-load child routing configurations or legacy NgModules, while loadComponent is used to directly lazy-load a single Standalone component without any module wrapper.

How do you configure preloading strategies in modern Standalone Angular apps?

Use provideRouter(routes, withPreloading(PreloadAllModules)) inside app.config.ts instead of passing options to RouterModule.forRoot().

How does the @defer block differ from route-level lazy loading in Angular?

Route lazy loading splits entire pages into separate JavaScript bundles fetched on URL change, whereas @defer allows granular template-level lazy loading of specific components or widgets based on user interactions (e.g., viewport scroll, hover, or idle).

How do you create a custom route preloading strategy in Angular?

Implement the PreloadingStrategy interface, check route data properties (e.g., if (route.data?.preload) return load()), and register the class in provideRouter(routes, withPreloading(MyCustomStrategy)).

Related Articles

Share this article

If you found this guide helpful, consider sharing it with your team or fellow developers.


Real Software. Real Lessons.

I share the lessons I learned the hard way, so you can either avoid them or be ready when they happen.

User avatar
User avatar
User avatar
User avatar
+13K

Join 13,800+ developers and readers.

No spam ever. Unsubscribe at any time.