| |

Angular 97 🅰️ Lazy Loading Strategies

⏱️ Estimated read: 17 min | |

The previous chapter covered bundle sizes and the loading techniques that reduce them. This chapter goes deeper into the specific strategies for loading code on demand. Lazy loading is not a single technique. It is a family of strategies: route-level lazy loading with loadComponent and loadChildren, component-level deferral with @defer, and the preloading strategies that decide when the deferred code should be fetched. Each strategy has its own trigger, its own effect on the initial bundle, and its own trade-offs.

The Angular router provides the primary mechanism for lazy loading: the loadComponent and loadChildren functions. The @defer block provides the component-level mechanism. The preloading strategies determine whether the lazy chunks are loaded on demand or in the background. The combination of these techniques determines how the application’s code is split, when each chunk is downloaded, and what the user experiences while waiting.

Key point: Every lazy loading strategy is about delay. The initial bundle contains the code the user needs immediately. Everything else is loaded later. The art is deciding what “immediately” means. A route that the user always visits should be in the initial bundle. A route the user visits occasionally should be lazy-loaded. A component below the fold should be deferred. A component behind an interaction should be deferred until the interaction. The decision determines the initial bundle size and the perceived performance.


Why loading strategies matter

A lazy-loaded chunk is not free. The user must download it, parse it, and execute it before the route or component is rendered. If the chunk is loaded on navigation, the user waits while the chunk downloads. The preloading strategy hides the latency by downloading the chunk in the background. The @defer block hides the latency by showing a placeholder while the chunk downloads.

The navigation delay problem. A lazy-loaded route is not in the initial bundle. When the user navigates to the route, the chunk must be downloaded. On a fast connection, the download is quick. On a slow connection, the user sees a blank screen or a loading indicator while the chunk downloads. The preloading strategy reduces the delay by downloading the chunk before the user navigates.

The initial bundle problem. Every chunk in the initial bundle increases the download time. The lazy loading strategies reduce the initial bundle by moving code out of it. The preloading strategies load the code after the initial render, so the user does not wait. The combination reduces the initial bundle without increasing the navigation delay.

The trigger problem. The @defer block has triggers: idle, viewport, interaction, hover, immediate, timer, and when. Each trigger fires under different conditions. The choice of trigger determines when the component is loaded. The viewport trigger is the most common for below-the-fold content. The interaction trigger is used for user-initiated content.

The precedence problem. Preloading and @defer interact. A lazy route that is preloaded is downloaded in the background. A @defer component that is triggered by the viewport is downloaded when the placeholder enters the viewport. The preloading strategy does not affect the @defer triggers. The two mechanisms are independent.

The trade-off. More chunks mean more requests. HTTP/2 and HTTP/3 multiplex the requests, so the overhead is minimal. But the browser must still parse and execute each chunk. The chunks should be small enough to download quickly and large enough to justify the request. The bundler handles the splitting. The developer decides which routes and components to split.


a. Route-Level Lazy Loading

Route-level lazy loading is the primary technique. A route that is lazy-loaded is not in the initial bundle. Its code is placed in a separate chunk and downloaded when the route is activated.

The loadComponent function lazy-loads a single component:

export const routes: Routes = [
  {
    path: 'dashboard',
    loadComponent: () => import('./dashboard/dashboard.component').then(m => m.DashboardComponent),
  },
];

The import() function returns a promise that resolves to the module. The bundler sees the dynamic import and creates a separate chunk. The chunk is downloaded when the router activates the route.

The loadChildren function lazy-loads an array of child routes:

export const routes: Routes = [
  {
    path: 'admin',
    loadChildren: () => import('./admin/admin.routes').then(m => m.ADMIN_ROUTES),
  },
];

The child routes are defined in a separate file. The file and all the components it imports are placed in a separate chunk. The chunk is downloaded when the route is activated.

The loadChildren function can also load a standalone component that defines its own routes:

export const routes: Routes = [
  {
    path: 'admin',
    loadChildren: () => import('./admin/admin.routes').then(m => m.ADMIN_ROUTES),
  },
];

The choice between loadComponent and loadChildren depends on the structure. A single component with no child routes uses loadComponent. A feature area with multiple components and child routes uses loadChildren.

The routes can be nested. A lazy-loaded route can contain more lazy-loaded routes. The bundler creates a separate chunk for each lazy-loaded level. The chunks are loaded as the user navigates deeper into the application.

export const routes: Routes = [
  {
    path: 'admin',
    loadChildren: () => import('./admin/admin.routes').then(m => m.ADMIN_ROUTES),
  },
];

// admin.routes.ts
export const ADMIN_ROUTES: Routes = [
  {
    path: '',
    loadComponent: () => import('./admin-home.component').then(m => m.AdminHomeComponent),
  },
  {
    path: 'users',
    loadComponent: () => import('./admin-users.component').then(m => m.AdminUsersComponent),
  },
  {
    path: 'settings',
    loadComponent: () => import('./admin-settings.component').then(m => m.AdminSettingsComponent),
  },
];

The admin route is lazy-loaded. The admin chunk contains the routes file but not the components. The components are loaded when their specific routes are activated. The nested lazy loading creates smaller chunks that are loaded only when needed.


b. Component-Level Deferral with @defer

The @defer block is the component-level mechanism. It defers the loading of a component and its dependencies until a trigger fires. The component is placed in a separate chunk. The chunk is downloaded when the trigger fires.

@defer (on viewport) {
  <app-heavy-chart [data]="chartData()" />
} @placeholder {
  <div class="chart-placeholder">Chart loads when visible</div>
} @loading (minimum 500ms) {
  <div class="spinner">Loading chart...</div>
} @error {
  <div class="error">Failed to load chart.</div>
}

The @defer block has four parts: the main content, the @placeholder, the @loading, and the @error. The main content is the component that is deferred. The @placeholder is shown before the trigger fires. The @loading is shown while the chunk is downloading. The @error is shown if the download fails.

The triggers are:

idle is the default. It loads when the browser is idle.

viewport loads when the placeholder enters the viewport.

interaction loads when the user clicks or presses a key on the placeholder.

hover loads when the user hovers over the placeholder.

immediate loads immediately after the non-deferred content.

timer(duration) loads after the specified duration.

when condition loads when the condition becomes truthy.

Multiple triggers can be combined with semicolons. The @defer (on viewport; on timer(5s)) fires when either the viewport trigger or the timer trigger fires.

The @placeholder and @loading blocks have optional minimum and after parameters. The minimum parameter sets the minimum time the block is shown. The after parameter sets the delay before the block is shown.

@defer (on viewport) {
  <app-heavy-chart />
} @placeholder (minimum 500ms) {
  <div class="placeholder">Chart placeholder</div>
} @loading (after 100ms; minimum 1s) {
  <div class="spinner">Loading...</div>
}

The @defer block can be combined with prefetch. The prefetch on idle option downloads the chunk when the browser is idle, but does not render the component until the trigger fires.

@defer (on viewport; prefetch on idle) {
  <app-heavy-chart />
}

The prefetch option is the component-level equivalent of the route-level preloading strategy.


c. Preloading Strategies

The preloading strategies determine whether the lazy chunks are loaded on demand or in the background. The Angular router provides the PreloadAllModules strategy and the PreloadingStrategy interface for custom strategies.

The PreloadAllModules strategy preloads all lazy routes after the initial application is loaded. The routes are still lazy-loaded in the sense that they are not in the initial bundle, but the chunks are downloaded in the background so that the user does not wait when navigating.

import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes, withPreloading(PreloadAllModules)),
  ],
};

The PreloadAllModules strategy is the right choice for most applications. The initial bundle is small, and the lazy chunks are preloaded in the background. The user gets the best of both worlds: a fast initial load and a fast navigation.

A custom preloading strategy implements the PreloadingStrategy interface. The preload method receives the route and a function that returns the load result. The method returns an observable that determines whether the route is preloaded.

import { Injectable } from '@angular/core';
import { PreloadingStrategy, Route } from '@angular/router';
import { Observable, of, timer } from 'rxjs';
import { mergeMap } from 'rxjs/operators';

@Injectable({ providedIn: 'root' })
export class SelectivePreloadingStrategy implements PreloadingStrategy {
  preload(route: Route, load: () => Observable<unknown>): Observable<unknown> {
    if (route.data?.['preload'] === true) {
      return timer(1000).pipe(mergeMap(() => load()));
    }
    return of(null);
  }
}

The strategy checks the route’s data property. If the preload flag is true, the route is preloaded after a one-second delay. The delay gives the browser time to finish the initial render before starting the background downloads.

The custom strategy is registered in the router configuration:

import { provideRouter, withPreloading } from '@angular/router';
import { SelectivePreloadingStrategy } from './selective-preloading.strategy';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes, withPreloading(SelectivePreloadingStrategy)),
  ],
};

The route declares whether it should be preloaded:

export const routes: Routes = [
  {
    path: 'dashboard',
    loadComponent: () => import('./dashboard.component').then(m => m.DashboardComponent),
    data: { preload: true },
  },
  {
    path: 'admin',
    loadChildren: () => import('./admin.routes').then(m => m.ADMIN_ROUTES),
    data: { preload: false },
  },
];

The dashboard route is preloaded. The admin route is not. The strategy decides based on the route’s data.


Complete Example Session

This session demonstrates the three lazy loading strategies on a small application.

// ============================================
// PART 1: THE ROUTES
// ============================================

// app.routes.ts
import { Routes } from '@angular/router';

export const routes: Routes = [
  {
    path: '',
    loadComponent: () => import('./home/home.component').then(m => m.HomeComponent),
    data: { preload: true },
  },
  {
    path: 'dashboard',
    loadComponent: () => import('./dashboard/dashboard.component').then(m => m.DashboardComponent),
    data: { preload: true },
  },
  {
    path: 'admin',
    loadChildren: () => import('./admin/admin.routes').then(m => m.ADMIN_ROUTES),
    data: { preload: false },
  },
  {
    path: 'settings',
    loadComponent: () => import('./settings/settings.component').then(m => m.SettingsComponent),
    data: { preload: false },
  },
];

// ============================================
// PART 2: THE ADMIN ROUTES
// ============================================

// admin/admin.routes.ts
import { Routes } from '@angular/router';

export const ADMIN_ROUTES: Routes = [
  {
    path: '',
    loadComponent: () => import('./admin-home.component').then(m => m.AdminHomeComponent),
  },
  {
    path: 'users',
    loadComponent: () => import('./admin-users.component').then(m => m.AdminUsersComponent),
  },
  {
    path: 'settings',
    loadComponent: () => import('./admin-settings.component').then(m => m.AdminSettingsComponent),
  },
];

// ============================================
// PART 3: THE PRELOADING STRATEGY
// ============================================

// selective-preloading.strategy.ts
import { Injectable } from '@angular/core';
import { PreloadingStrategy, Route } from '@angular/router';
import { Observable, of, timer } from 'rxjs';
import { mergeMap } from 'rxjs/operators';

@Injectable({ providedIn: 'root' })
export class SelectivePreloadingStrategy implements PreloadingStrategy {
  preload(route: Route, load: () => Observable<unknown>): Observable<unknown> {
    if (route.data?.['preload'] === true) {
      return timer(1000).pipe(mergeMap(() => load()));
    }
    return of(null);
  }
}

// ============================================
// PART 4: THE APP CONFIG
// ============================================

// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withPreloading } from '@angular/router';
import { SelectivePreloadingStrategy } from './selective-preloading.strategy';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes, withPreloading(SelectivePreloadingStrategy)),
  ],
};

// ============================================
// PART 5: THE DASHBOARD COMPONENT WITH @DEFER
// ============================================

// dashboard/dashboard.component.ts
import { Component, signal } from '@angular/core';

@Component({
  selector: 'app-dashboard',
  template: `
    <h1>Dashboard</h1>
    <app-summary [data]="summary()" />

    @defer (on viewport; prefetch on idle) {
      <app-heavy-chart [data]="chartData()" />
    } @placeholder (minimum 500ms) {
      <div class="chart-placeholder">Chart loads when visible</div>
    } @loading (after 100ms; minimum 1s) {
      <div class="spinner">Loading chart...</div>
    } @error {
      <div class="error">Failed to load chart.</div>
    }
  `,
})
export class DashboardComponent {
  summary = signal<Summary | null>(null);
  chartData = signal<ChartData | null>(null);
}

// ============================================
// PART 6: THE BUILD OUTPUT
// ============================================

// ng build --configuration production

// Initial total | 380 kB
// Lazy          | 420 kB
//   - home      |  20 kB
//   - dashboard | 120 kB
//   - admin     | 180 kB
//   - settings  | 100 kB
//   - chart     |  60 kB (deferred)

// The initial bundle contains only the shell.
// The lazy chunks are loaded based on the strategy.

// ============================================
// PART 7: THE PRELOADING BEHAVIOR
// ============================================

// The home and dashboard routes have preload: true.
// After the initial render, the strategy preloads them.
// The admin and settings routes have preload: false.
// They are loaded on navigation.

// The user experience:
// - Initial load: fast
// - Navigate to dashboard: instant (preloaded)
// - Navigate to admin: short wait (loaded on navigation)
// - Navigate to settings: short wait (loaded on navigation)

// ============================================
// PART 8: THE @DEFER BEHAVIOR
// ============================================

// The heavy chart is deferred with on viewport.
// The chart chunk is not in the dashboard chunk.
// When the dashboard is rendered, the chart is not loaded.
// When the placeholder enters the viewport, the chart chunk is downloaded.
// The prefetch on idle downloads the chart chunk when the browser is idle.

// The user experience:
// - Dashboard renders with a placeholder
// - The chart chunk is prefetched in the background
// - When the placeholder enters the viewport, the chart renders

// ============================================
// PART 9: THE OPTIMIZATION CHECKLIST
// ============================================

// 1. Lazy load all routes that are not on the initial page
// 2. Use @defer for components below the fold
// 3. Use PreloadAllModules or a custom strategy
// 4. Use prefetch on idle with @defer
// 5. Set bundle budgets
// 6. Analyze the bundle
// 7. Remove unused dependencies
// 8. Use the production build
// 9. Monitor the bundle size in CI
// 10. Test the loading behavior on a slow connection

// ============================================
// PART 10: THE RESULTS
// ============================================

// Before optimization:
// Initial total | 1.45 MB
// Time to interactive (4G) | 3.2s

// After optimization:
// Initial total | 380 kB
// Time to interactive (4G) | 1.1s
// Dashboard navigation | instant (preloaded)
// Chart render | instant (prefetched)

// The initial bundle is 73% smaller.
// The time to interactive is 66% faster.
// The preloading hides the navigation latency.
// The prefetch hides the defer latency.

The ten parts cover the routes, the admin routes, the preloading strategy, the app config, the dashboard component with @defer, the build output, the preloading behavior, the @defer behavior, the optimization checklist, and the results.


Quick Reference

The Lazy Loading Functions

FunctionPurpose
loadComponentLazy load a single component
loadChildrenLazy load an array of routes
import()Dynamic import

The @defer Triggers

TriggerFires When
idleBrowser is idle
viewportElement enters viewport
interactionClick or key press
hoverMouse hovers
immediateAfter non-deferred content
timer(duration)After the duration
when conditionCondition is truthy

The @defer Prefetch Options

OptionPurpose
prefetch on idleDownload when idle
prefetch on viewportDownload when in viewport
prefetch on hoverDownload on hover
prefetch when conditionDownload when condition is truthy

The Preloading Strategies

StrategyBehavior
PreloadAllModulesPreload all lazy routes
NoPreloadingPreload nothing
Custom strategyPreload based on route data

The PreloadingStrategy Interface

MethodPurpose
preload(route, load)Return observable that determines preload

Best Practices

✅ Do This:

// Lazy load all non-initial routes
loadComponent: () => import('./dashboard.component').then(m => m.DashboardComponent) // ✅
<!-- Defer below-the-fold components with viewport trigger -->
@defer (on viewport) { <heavy-chart /> }                         <!-- ✅ -->
<!-- Prefetch deferred components on idle -->
@defer (on viewport; prefetch on idle) { <heavy-chart /> }       <!-- ✅ -->
// Use a custom preloading strategy
provideRouter(routes, withPreloading(SelectivePreloadingStrategy)) // ✅
// Declare preload in route data
{ path: 'dashboard', data: { preload: true } }                   // ✅

❌ Don’t Do This:

// Don't statically import lazy components
import { DashboardComponent } from './dashboard.component';      // ❌
<!-- Don't defer critical content -->
@defer (on idle) { <app-header /> }                              <!-- ❌ -->
// Don't preload everything without a strategy
provideRouter(routes, withPreloading(PreloadAllModules))         // ⚠️ can be slow
<!-- Don't forget the placeholder -->
@defer (on viewport) { <heavy-chart /> }                         <!-- ⚠️ no placeholder -->

Common Pitfalls

PitfallWhy It HappensFix
Lazy route not loadingWrong import pathCheck the path
@defer never triggersWrong triggerCheck the trigger
Preloading too slowPreloading everythingUse a selective strategy
Placeholder not shownMissing @placeholderAdd the block
Chunk size too largeNot splitting enoughSplit into smaller chunks
Duplicate chunksShared dependenciesUse the bundler’s shared chunks

Real-World Examples

1. Lazy Load a Component

loadComponent: () => import('./dashboard.component').then(m => m.DashboardComponent)

2. Lazy Load Routes

loadChildren: () => import('./admin.routes').then(m => m.ADMIN_ROUTES)

3. Defer on Viewport

@defer (on viewport) { <heavy-chart /> }

4. Defer with Prefetch

@defer (on viewport; prefetch on idle) { <heavy-chart /> }

5. Defer on Interaction

@defer (on interaction) { <comments /> }

6. Preload All Modules

provideRouter(routes, withPreloading(PreloadAllModules))

7. Custom Preloading Strategy

preload(route: Route, load: () => Observable<unknown>): Observable<unknown>

8. Route Data Preload Flag

data: { preload: true }

9. Placeholder with Minimum

@placeholder (minimum 500ms) { <div>Loading...</div> }

10. Loading with After

@loading (after 100ms; minimum 1s) { <div>Loading...</div> }

Visual

The Lazy Loading Strategies

┌──────────────────────────────────────────────┐
│  LAZY LOADING STRATEGIES                     │
│                                              │
│  Route-level:                                │
│    └─ loadComponent, loadChildren            │
│    └─ Chunk loaded on navigation             │
│                                              │
│  Component-level:                            │
│    └─ @defer with triggers                   │
│    └─ Chunk loaded on trigger                │
│                                              │
│  Preloading:                                 │
│    └─ Chunk loaded in background             │
│    └─ User does not wait on navigation       │
│                                              │
└──────────────────────────────────────────────┘

The Preloading Flow

┌──────────────────────────────────────────────┐
│  PRELOADING FLOW                             │
│                                              │
│  1. Initial load                             │
│     └─ main.js + polyfills.js + styles.css   │
│                                              │
│  2. Application renders                      │
│     └─ Shell visible                         │
│                                              │
│  3. Preloading starts                        │
│     └─ Lazy chunks downloaded in background  │
│                                              │
│  4. User navigates                           │
│     └─ Chunk already cached                  │
│     └─ Navigation is instant                 │
│                                              │
└──────────────────────────────────────────────┘

The @defer with Prefetch

┌──────────────────────────────────────────────┐
│  @defer WITH PREFETCH                        │
│                                              │
│  @defer (on viewport; prefetch on idle) {    │
│    <heavy-chart />                           │
│  }                                           │
│                                              │
│  Timeline:                                   │
│  1. Initial render                           │
│     └─ @placeholder shown                    │
│                                              │
│  2. Browser idle                             │
│     └─ Chart chunk prefetched                │
│                                              │
│  3. Chart enters viewport                    │
│     └─ Chart renders instantly               │
│                                              │
│  The prefetch hides the download time.       │
│                                              │
└──────────────────────────────────────────────┘

The Selective Preloading

┌──────────────────────────────────────────────┐
│  SELECTIVE PRELOADING                        │
│                                              │
│  Routes:                                     │
│    home       → preload: true                │
│    dashboard  → preload: true                │
│    admin      → preload: false               │
│    settings   → preload: false               │
│                                              │
│  After initial load:                         │
│    home and dashboard chunks downloaded      │
│                                              │
│  On navigation to admin:                     │
│    admin chunk downloaded (short wait)       │
│                                              │
│  On navigation to settings:                  │
│    settings chunk downloaded (short wait)    │
│                                              │
│  The strategy decides based on route data.   │
│                                              │
└──────────────────────────────────────────────┘

Summary

ItemValue
Route-level lazy loadingloadComponent, loadChildren
Component-level deferral@defer with triggers
Triggersidle, viewport, interaction, hover, immediate, timer, when
Prefetchprefetch on idle, prefetch on viewport
Preloading strategyPreloadAllModules, custom strategy
PreloadingStrategy interfacepreload(route, load)
Route data preload flagdata: { preload: true }
Placeholder@placeholder block
Loading@loading block with after and minimum
Error@error block

Key takeaways:

  • Route-level lazy loading is the primary technique. The loadComponent and loadChildren functions place the route’s code in a separate chunk. The chunk is downloaded when the route is activated. The initial bundle contains only the shell .
  • @defer is the component-level mechanism. The component and its dependencies are placed in a separate chunk. The chunk is downloaded when a trigger fires. The viewport trigger is the most common for below-the-fold content. The interaction trigger is used for user-initiated content .
  • The preloading strategies determine whether the lazy chunks are loaded on demand or in the background. PreloadAllModules preloads all lazy routes. A custom PreloadingStrategy preloads only the routes that match the strategy’s conditions .
  • The prefetch option with @defer downloads the chunk before the trigger fires. The prefetch on idle option downloads the chunk when the browser is idle. When the trigger fires, the chunk is already cached, and the component renders instantly .
  • The @placeholder, @loading, and @error blocks provide the user experience. The placeholder is shown before the trigger fires. The loading is shown while the chunk downloads. The error is shown if the download fails. The minimum and after parameters control the timing .
  • The choice of strategy determines the initial bundle size and the perceived performance. A route that the user always visits should be in the initial bundle or preloaded. A route the user visits occasionally should be lazy-loaded. A component below the fold should be deferred with the viewport trigger. The decision determines the balance between initial load time and navigation latency.
  • The combination of techniques provides the best performance. Lazy loading reduces the initial bundle. Preloading hides the navigation latency. @defer with prefetch hides the component loading latency. The bundle budgets prevent regressions. The build analyzer identifies the opportunities.

Remember: Lazy loading is not a single technique. It is a family of strategies. Route-level lazy loading splits the application into chunks. @defer splits the components. The preloading strategies load the chunks in the background. The prefetch options download the deferred components before the trigger fires. The placeholder and loading blocks provide the user experience. The combination determines the initial bundle size, the navigation latency, and the perceived performance. The goal is to load the code the user needs when the user needs it, and nothing before.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!