Lazy Loading Feature Modules with the Angular Router

After reading this you will be able to split an Angular application at its route boundaries so each feature area downloads only when a user first navigates into it, and then tune the first-click latency with a preloading strategy that fetches those chunks in the background.

← Back to Angular Router Configuration

Prerequisites

Core Concept

Lazy loading in the Angular Router is nothing more exotic than a route whose target is a function returning a dynamic import(). Instead of component: AdminComponent, which forces the component and its transitive imports into the initial bundle, you write loadComponent: () => import('./admin.component').then(m => m.AdminComponent). The CLI sees the dynamic import at build time, carves that dependency graph into its own chunk, and the router only requests that chunk when the route first matches. For a whole feature area — many routes and components — loadChildren points at a child Routes array (or, in legacy apps, an NgModule), and the entire sub-tree becomes one lazily loaded unit. This is the framework’s concrete expression of route-based code splitting: the route table doubles as the split-point map, so first paint carries only the shell and the landing route rather than the admin console a visitor may never open.

Implementation

The configuration below splits an admin feature behind loadChildren, lazily loads a single standalone component with loadComponent, gates the feature with CanMatch so unauthorized users never fetch the chunk, and registers a preloading strategy so authorized users get the chunk fetched in the background before they click.

// Angular 17+ (@angular/router) — app.routes.ts and main.ts

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

export const routes: Routes = [
  { path: '', loadComponent: () => import('./home.component').then(m => m.HomeComponent) },
  {
    path: 'reports/:id',
    // A single lazily loaded standalone component:
    loadComponent: () => import('./reports/report.component').then(m => m.ReportComponent),
  },
  {
    path: 'admin',
    // CanMatch refuses BEFORE the lazy chunk is fetched, unlike CanActivate.
    canMatch: [() => import('./admin/admin.guard').then(m => m.canEnterAdmin)],
    // An entire feature sub-tree loaded as one chunk:
    loadChildren: () => import('./admin/admin.routes').then(m => m.ADMIN_ROUTES),
    data: { preload: true }, // consumed by the custom strategy below
  },
  { path: '**', loadComponent: () => import('./not-found.component').then(m => m.NotFoundComponent) },
];

// --- admin/admin.routes.ts (the lazily loaded child tree) ---
// export const ADMIN_ROUTES: Routes = [
//   { path: '', loadComponent: () => import('./dashboard.component').then(m => m.DashboardComponent) },
//   { path: 'users', loadComponent: () => import('./users.component').then(m => m.UsersComponent) },
// ];

// --- main.ts ---
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter, withPreloading, PreloadingStrategy, Route } from '@angular/router';
import { Observable, of } from 'rxjs';
import { Injectable } from '@angular/core';
import { AppComponent } from './app/app.component';
import { routes } from './app/app.routes';

@Injectable({ providedIn: 'root' })
export class SelectivePreload implements PreloadingStrategy {
  // Preload only routes flagged with data.preload; skip the rest.
  preload(route: Route, load: () => Observable<unknown>): Observable<unknown> {
    return route.data?.['preload'] ? load() : of(null);
  }
}

bootstrapApplication(AppComponent, {
  providers: [provideRouter(routes, withPreloading(SelectivePreload))],
});

Swap SelectivePreload for the built-in PreloadAllModules to eagerly fetch every lazy chunk after the app is interactive, or NoPreloading (the default) to fetch strictly on demand. withPreloading is the standalone form; the NgModule equivalent is RouterModule.forRoot(routes, { preloadingStrategy: SelectivePreload }).

What a lazy route boundary defers and what it cannot Five items classified by whether a lazy boundary defers them: components, providers and route definitions are deferred, shared library code is extracted, and the boundary's own guard must load eagerly. What a lazy boundary actually defers deferred? note the feature's components yes the point of the split its providers yes — created on entry destroyed on exit if route-scoped its route definitions yes the parent only knows the path prefix shared library code no — extracted or it would be duplicated the guard on the boundary no — it must run first keep guards out of the lazy chunk
The last row is the one that catches people: a guard inside the lazy chunk cannot decide whether to load it.

Verification

Confirm the split at the network layer — a lazy route must produce its own chunk that is absent from the initial load and fetched only on first navigation.

// @playwright/test v1.44
import { test, expect } from '@playwright/test';

test('admin chunk loads only on first navigation', async ({ page }) => {
  const chunkRequests: string[] = [];
  page.on('request', r => {
    if (/admin.*\.js$/.test(r.url())) chunkRequests.push(r.url());
  });

  await page.goto('/');            // shell only — no admin chunk yet
  expect(chunkRequests).toHaveLength(0);

  await page.click('a[href="/admin"]');
  await expect(page).toHaveURL(/\/admin/);
  expect(chunkRequests.length).toBeGreaterThan(0); // chunk fetched on demand
});

For a manual check, open the DevTools Network panel filtered to JS, load the home route, and confirm no admin chunk appears; click into the feature and watch exactly one new chunk arrive. Re-clicking must not refetch it — the module is cached in memory after the first load.

Four preloading strategies for lazy Angular areas Four preloading approaches: no preloading, preloading everything after the first paint, a custom strategy driven by route data, and hover-triggered preloading for dense menus. Preloading strategies for lazy areas strategy suits NoPreloading (default) load only on navigation many rarely used areas PreloadAllModules warm everything after the first paint a handful of small areas a custom strategy on route data preload only flagged routes the usual production choice preload on link hover warm exactly what is hovered dense navigation menus
Preloading everything is only correct when the total of all lazy chunks is small enough that splitting them barely mattered.

Gotchas

  • CanActivate still downloads the chunk. Guarding a lazy route with CanActivate fetches the bundle before rejecting the user. Use CanMatch when the goal is to keep code out of unauthorized hands, not merely block rendering.
  • PreloadAllModules on a metered connection. Eagerly preloading every chunk can pull megabytes a visitor never needs. Prefer a selective strategy that preloads only high-probability routes flagged in data.
  • Shared dependency duplication. If two lazy chunks each import a heavy library that is not in the initial bundle, the CLI may duplicate it. Keep genuinely shared code in a common eager chunk or a shared lazy boundary so it deduplicates.
  • A guard that itself imports the feature. Referencing feature code from an eagerly evaluated guard defeats the split by dragging that graph back into the main bundle. Keep guard logic and its dependencies lightweight and eager.

One further consideration decides whether a lazy boundary is worth drawing at all: how often the area is visited. Splitting out a settings screen that ninety per cent of users open once saves those users nothing on the visit that matters and costs them a round trip on the visit that does. Splitting out a reporting area that only administrators reach removes its entire weight from everyone else’s first load. The useful signal is the proportion of sessions that enter the area, and it is usually already in your analytics.

The second signal is size. A lazy chunk below roughly ten kilobytes rarely repays its own request, because the fixed cost of resolving and fetching a module is comparable to the bytes saved. Below that threshold, merging the area back into the parent chunk is the faster choice, and most bundlers can be told to do it automatically rather than requiring the boundary to be removed by hand.

FAQ

What is the difference between loadChildren and loadComponent? loadComponent lazily loads a single standalone component for one route, while loadChildren lazily loads an entire child route array (or legacy NgModule) as one chunk covering a whole feature area. Use loadComponent for leaf routes and loadChildren when a feature has several nested routes.

Why should I prefer CanMatch over CanActivate on a lazy route? CanMatch decides whether the route participates in matching at all, so a rejected user never triggers the dynamic import and never downloads the chunk. CanActivate runs after the match is found, which means the lazy bundle is already fetched before the guard can reject the navigation.

Does preloading slow down my initial page load? No, when done correctly. Preloading strategies run after the application is bootstrapped and interactive, fetching lazy chunks in idle background time, so first paint is unaffected while the first click into a preloaded feature becomes instant.

Do I still need NgModules to lazy load in modern Angular? No. Standalone components with loadComponent and a plain exported Routes array behind loadChildren cover lazy loading fully without any NgModule. The NgModule form still works for legacy codebases but is no longer required.

How do I confirm a route is actually being split into its own chunk? Check the build output or bundle report for a separately named chunk, then use the DevTools Network panel to verify that chunk is absent on initial load and requested only when you first navigate into the route.

Service lifetime by where it is provided Four provider scopes with the resulting service lifetime: root lives forever, a lazy route's providers scope it to that subtree, two sibling routes produce two instances, and component-level providers create one per instance. Where a lazily provided service lives provided in lifetime root the whole application created once, never destroyed a lazy route's providers that route subtree created on entry, destroyed on exit two sibling routes two separate injectors two instances, split state a component that component instance one per instance
The third row produces state that silently splits in two — the same service, two injectors, no error.