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 }).
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.
Gotchas
CanActivatestill downloads the chunk. Guarding a lazy route withCanActivatefetches the bundle before rejecting the user. UseCanMatchwhen the goal is to keep code out of unauthorized hands, not merely block rendering.PreloadAllModuleson 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 indata.- 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.
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.
Related
- Angular Router Configuration — the parent guide covering the full router: routes, outlets, guards, resolvers, and location strategies.
- Route-Based Code Splitting — the framework-agnostic principle that loadChildren and loadComponent implement in Angular.
- React Router Implementation — how the same lazy-loading idea is expressed with React Router’s lazy route objects and data APIs.