Angular 25 🅰️ Lazy Loading Routes
Every byte the browser downloads costs time — parse time, network time, memory. A large Angular app can easily ship megabytes of JavaScript, and loading it all up front delays the first meaningful paint. Lazy loading splits the app into chunks that load on demand. The initial bundle contains only what’s needed to render the first route; everything else waits until the user navigates to it. Angular’s router supports lazy loading at two levels: loadComponent for a single standalone component and loadChildren for a group of child routes. This chapter covers both, plus the bundling behavior, preloading strategies, and the patterns that keep the initial payload small.
Key point: Lazy loading is a routing feature. You mark a route as lazy with loadComponent or loadChildren, and Angular bundles that code into a separate file. The file isn’t fetched until the route activates. The first route stays eager (bundled in main.js), and every lazy route becomes its own chunk. The result: fast initial load, delayed download of the parts users may never visit.
What lazy loading is
Lazy loading defers downloading code until it’s needed.
Eager loading — the default:
import { Routes } from '@angular/router';
import { HomeComponent } from './home.component';
import { AdminComponent } from './admin/admin.component';
export const routes: Routes = [
{ path: '', component: HomeComponent },
{ path: 'admin', component: AdminComponent }
];
Both components are imported at the top. The bundler includes both in the main bundle. The browser downloads both on initial load, even if the user only visits /.
Lazy loading:
export const routes: Routes = [
{ path: '', component: HomeComponent },
{ path: 'admin', loadComponent: () => import('./admin/admin.component').then(m => m.AdminComponent) }
];
AdminComponent is loaded via a dynamic import. The bundler puts it in a separate chunk. The browser fetches it only when the user navigates to /admin.
What lazy loading does:
- Splits the app into chunks
- Defers download of unused code
- Reduces the initial bundle
- Speeds up the first render
What it doesn’t do:
- Doesn’t reduce total code (all chunks still ship)
- Doesn’t cache by itself (the browser caches like any file)
- Doesn’t help if the user visits every route
When it helps: Large apps where users touch a fraction of the routes. Dashboards, admin panels, e-commerce — anywhere the initial view is simple and the rest is deep.
When it doesn’t: Small apps where everything loads fast anyway. Adding lazy loading to a 50 KB app adds complexity for no gain.
Why the router handles it: The router knows when a route activates. That’s exactly the moment the code is needed. Bundling the chunk is a build-time concern; loading it is a runtime one. The router coordinates both through the dynamic import.
Why “lazy”: The code is available but not loaded until asked. It’s lazy — it doesn’t do work until it has to. The name describes the deferral, not the code itself.
loadComponent — lazy-load a standalone component
loadComponent lazy-loads a single standalone component.
export const routes: Routes = [
{
path: 'settings',
loadComponent: () =>
import('./settings/settings.component').then(m => m.SettingsComponent)
}
];
The arrow function returns a promise of the component. Angular calls it when the route activates.
What the dynamic import does:
import('./path')tells the bundler to create a separate chunk- The promise resolves to the module object
.then(m => m.SettingsComponent)extracts the component
The signature: loadComponent returns Promise<Type<unknown>> — a promise of a component class.
Multiple lazy components:
export const routes: Routes = [
{ path: '', loadComponent: () => import('./home.component').then(m => m.HomeComponent) },
{ path: 'about', loadComponent: () => import('./about.component').then(m => m.AboutComponent) },
{ path: 'contact', loadComponent: () => import('./contact.component').then(m => m.ContactComponent) }
];
Each route becomes its own chunk. The main bundle contains only the router and the shell.
With route params:
export const routes: Routes = [
{
path: 'users/:id',
loadComponent: () =>
import('./users/user-detail.component').then(m => m.UserDetailComponent)
}
];
The component is lazy; the route config is eager. Params work the same — the component reads them via ActivatedRoute.
With guards and resolvers:
export const routes: Routes = [
{
path: 'admin',
loadComponent: () =>
import('./admin/admin.component').then(m => m.AdminComponent),
canActivate: [authGuard],
resolve: { data: adminDataResolver }
}
];
Guards run before loading the component. If the guard denies access, the chunk isn’t downloaded. That’s a nice property — protected code stays unloaded.
When loadComponent is right:
- A single standalone component per route
- Small features that don’t need a whole route group
- Migrating from module-based lazy loading
Why loadComponent over loadChildren: loadComponent is simpler. Use it when the lazy route is one component. Use loadChildren when the lazy route has its own sub-routes.
Why “component” and not “module”: Standalone components don’t need modules.
loadComponentreflects that — it loads just the component.loadChildrenremains for cases that need a route group.
loadChildren — lazy-load a route group
loadChildren lazy-loads a set of child routes.
export const routes: Routes = [
{
path: 'admin',
loadChildren: () =>
import('./admin/admin.routes').then(m => m.adminRoutes)
}
];
The dynamic import returns a Routes array. Angular merges those routes under /admin.
The admin.routes.ts file:
import { Routes } from '@angular/router';
export const adminRoutes: Routes = [
{ path: '', component: AdminHomeComponent },
{ path: 'users', component: UsersComponent },
{ path: 'settings', component: SettingsComponent }
];
Every route in the array is lazy-loaded together.
URLs work as expected:
/admin→AdminHomeComponent/admin/users→UsersComponent/admin/settings→SettingsComponent
The parent route’s path (admin) prefixes the children.
loadChildren with loadComponent inside: A lazy route group can use loadComponent for its own routes.
// admin.routes.ts
export const adminRoutes: Routes = [
{ path: '', loadComponent: () => import('./home.component').then(m => m.HomeComponent) },
{ path: 'users', loadComponent: () => import('./users.component').then(m => m.UsersComponent) }
];
That gives you lazy loading at two levels — the group loads as one chunk, and each route within it is its own chunk. Rarely needed, but possible.
Route guards for the whole group:
export const routes: Routes = [
{
path: 'admin',
loadChildren: () => import('./admin/admin.routes').then(m => m.adminRoutes),
canActivate: [authGuard]
}
];
The guard runs before the children load. If it fails, the group’s chunk isn’t fetched.
Route data for the group:
{
path: 'admin',
loadChildren: () => import('./admin/admin.routes').then(m => m.adminRoutes),
data: { section: 'admin' }
}
The data is inherited by all children.
When loadChildren is right:
- A feature with multiple routes
- An admin section, a shop, a blog
- Any group of routes that logically loads together
Why loadChildren over loadComponent: When the lazy route has more than one child. One component per route is loadComponent; a route group is loadChildren.
Why “children”: The routes you load become children of the route that loaded them. The parent path is their prefix. The naming reflects the structure — the lazy routes are children of the eager parent.
Legacy loadChildren syntax
Before Angular 15, loadChildren used a string and a # separator.
{
path: 'admin',
loadChildren: './admin/admin.module#AdminModule'
}
The string form still works but is deprecated. The new form — a function that returns a promise — is the standard.
The old form:
// Module-based lazy loading
loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule)
The function form (Angular 15+) is preferred.
Why the change: The string form relied on the bundler parsing a specific syntax. The function form uses standard dynamic import() — clearer, more portable, and works with any bundler.
Migration: Replace the string with a function.
// Before
loadChildren: './admin/admin.module#AdminModule'
// After
loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule)
Why you’ll see the old form: Older codebases and tutorials use it. New code should use the function form.
Why the function form wins: It’s a standard JavaScript feature. The bundler recognizes
import()and creates a chunk. The string form was Angular-specific — it required the bundler to understand Angular’s lazy-loading syntax. Function form works with webpack, Vite, esbuild — any bundler.
Bundling behavior
The bundler sees import() calls and creates chunks.
What you’ll see in the build output:
Initial chunk files | Names | Raw size
main.js | main | 250.00 kB
polyfills.js | polyfills | 100.00 kB
styles.css | styles | 5.00 kB
Lazy chunk files | Names | Raw size
chunk-ABC123.js | admin-routes | 80.00 kB
chunk-DEF456.js | settings-comp | 30.00 kB
The initial chunks download on first load. The lazy chunks download when their routes activate.
What determines a chunk:
- One
import()call → one chunk - Imports within a chunk are bundled together
- Shared code may be extracted into a common chunk
Shared code: If two lazy chunks both import Lodash, the bundler may extract Lodash into a shared chunk. Both lazy chunks reference it.
Common chunk patterns:
| Pattern | Bundler action |
|---|---|
| Two lazy chunks share code | Extract common chunk |
| Lazy chunk imports eager code | Reference the eager code |
| Eager imports lazy code | Undo lazy loading (bad) |
The “undo lazy loading” trap:
// admin.routes.ts (lazy)
import { SharedThing } from '../shared';
// app.routes.ts (eager)
import { AdminComponent } from './admin/admin.component'; // ❌ eager
export const routes: Routes = [
{ path: '', component: HomeComponent },
{ path: 'admin', loadChildren: () => import('./admin/admin.routes').then(m => m.adminRoutes) }
];
The import { AdminComponent } at the top of app.routes.ts pulls AdminComponent into the eager bundle. Now it’s loaded on first load, defeating the purpose.
The fix: Remove the eager import. Only the lazy import() should reference the lazy code.
// No top-level import of AdminComponent
export const routes: Routes = [
{ path: 'admin', loadChildren: () => import('./admin/admin.routes').then(m => m.adminRoutes) }
];
How to check: Run ng build and look at the chunk sizes. If a “lazy” chunk is small but the main bundle is large, an eager import is pulling code into the main bundle.
Why this matters: Bundling is where lazy loading actually happens. The router loads chunks at runtime, but the split is a build-time decision. If the build doesn’t split correctly, the runtime has nothing to defer.
Why the bundler sees imports:
import()is a standard JavaScript syntax that bundlers recognize as a chunk boundary. Staticimportstatements are part of the eager graph. Dynamicimport()creates a new graph. The bundler treats each dynamic import as a separate entry point — one chunk.
Preloading strategies
Lazy loading delays the download until the route activates. Preloading downloads the chunks in the background after the initial load, before the user navigates.
Preloading strategies:
| Strategy | Behavior |
|---|---|
NoPreloading | Default — no preloading |
PreloadAllModules | Load every lazy chunk after initial |
Custom | Load only selected chunks |
Enable preloading:
import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
bootstrapApplication(AppComponent, {
providers: [
provideRouter(routes, withPreloading(PreloadAllModules))
]
});
After the initial bundle loads, the router starts downloading all lazy chunks in the background.
What this gives: The first navigation to a lazy route is instant — the chunk is already cached. The cost is bandwidth — every lazy chunk is downloaded even if the user never visits those routes.
When preloading helps: Apps where users will likely visit most routes — dashboards, admin panels, internal tools.
When it doesn’t: Apps where most routes are rarely visited — public sites, docs, large catalogs. Preloading wastes bandwidth.
Custom preloading:
import { PreloadingStrategy, Route } from '@angular/router';
import { Observable, of } from 'rxjs';
export class SelectivePreloading implements PreloadingStrategy {
preload(route: Route, load: () => Observable<unknown>): Observable<unknown> {
return route.data?.['preload'] ? load() : of(null);
}
}
The strategy checks route.data.preload and only loads marked routes.
{
path: 'dashboard',
loadChildren: () => import('./dashboard/dashboard.routes').then(m => m.dashboardRoutes),
data: { preload: true }
}
Quicklink strategy: Third-party strategies exist that preload routes visible on screen. Rare, but useful for link-heavy apps.
Why preloading exists: Lazy loading trades initial speed for delayed navigation. Preloading gets the speed back — after the initial load, the chunks are fetched in the background, so by the time the user navigates, they’re cached. The best of both when the app supports it.
Why not always preload: Bandwidth and CPU cost. Downloading megabytes the user never uses wastes both. Preloading is a bet that the user will visit the routes. Sometimes the bet pays off; sometimes it doesn’t.
Why
NoPreloadingis the default: The safest choice. The user’s bandwidth is used only for what they visit. Apps that know their users well can opt into preloading.
Chunk naming
By default, the bundler generates hashed names. You can name chunks.
Named chunks with a comment:
loadChildren: () =>
import(/* webpackChunkName: "admin" */ './admin/admin.routes')
.then(m => m.adminRoutes)
The webpackChunkName comment tells webpack (and other bundlers that support it) to name the chunk admin.
Esbuild and Vite: Use a similar comment.
import(/* @vite-ignore */ './admin/admin.routes')
Or rely on the file name — modern bundlers often name chunks after the imported file.
Why naming matters: Hashed names like chunk-ABC123.js are hard to identify. Named chunks like admin.js make build output readable. Useful for debugging and for long-term cache management.
Not required: Naming is optional. The bundler works either way. Use it when the build output is hard to read.
Why the hashes: Hashed names include a content hash, so the browser caches by content. Change the code, the hash changes, the browser re-downloads. That’s cache-busting — an important production feature.
Naming + hashing: You can have both: admin.ABC123.js. The name identifies the chunk; the hash busts the cache. Many bundlers combine them.
Why
webpackChunkNameisn’t universal: It’s a webpack-specific magic comment. Other bundlers have their own conventions. Modern bundlers often infer names from the file path, so explicit naming is less necessary than it used to be.
A full example
An app with eager and lazy routes.
// ============================================
// EAGER COMPONENTS
// ============================================
import { Component } from '@angular/core';
import { RouterOutlet, RouterLink, RouterLinkActive } from '@angular/router';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, RouterLink, RouterLinkActive],
template: `
<header>
<nav>
<a routerLink="/" routerLinkActive="active" [routerLinkActiveOptions]="{ exact: true }">Home</a>
<a routerLink="/about" routerLinkActive="active">About</a>
<a routerLink="/admin" routerLinkActive="active">Admin</a>
<a routerLink="/shop" routerLinkActive="active">Shop</a>
</nav>
</header>
<main>
<router-outlet></router-outlet>
</main>
`
})
export class AppComponent {}
@Component({
selector: 'app-home',
standalone: true,
template: `<h1>Home</h1>`
})
export class HomeComponent {}
@Component({
selector: 'app-about',
standalone: true,
template: `<h1>About</h1>`
})
export class AboutComponent {}
// ============================================
// ROUTES
// ============================================
import { Routes } from '@angular/router';
import { HomeComponent } from './home.component';
import { AboutComponent } from './about.component';
export const routes: Routes = [
// Eager routes
{ path: '', component: HomeComponent, pathMatch: 'full' },
{ path: 'about', component: AboutComponent },
// Lazy group — admin
{
path: 'admin',
loadChildren: () =>
import(/* webpackChunkName: "admin" */ './admin/admin.routes')
.then(m => m.adminRoutes)
},
// Lazy group — shop
{
path: 'shop',
loadChildren: () =>
import(/* webpackChunkName: "shop" */ './shop/shop.routes')
.then(m => m.shopRoutes)
}
];
// ============================================
// ADMIN ROUTES (lazy)
// ============================================
import { Routes } from '@angular/router';
export const adminRoutes: Routes = [
{
path: '',
loadComponent: () => import('./admin-home.component').then(m => m.AdminHomeComponent)
},
{
path: 'users',
loadComponent: () => import('./users.component').then(m => m.UsersComponent)
},
{
path: 'settings',
loadComponent: () => import('./settings.component').then(m => m.SettingsComponent)
}
];
// ============================================
// SHOP ROUTES (lazy)
// ============================================
import { Routes } from '@angular/router';
export const shopRoutes: Routes = [
{
path: '',
loadComponent: () => import('./shop-home.component').then(m => m.ShopHomeComponent)
},
{
path: 'products',
loadComponent: () => import('./products.component').then(m => m.ProductsComponent)
},
{
path: 'cart',
loadComponent: () => import('./cart.component').then(m => m.CartComponent)
}
];
// ============================================
// BOOTSTRAP
// ============================================
import { bootstrapApplication } from '@angular/platform-browser';
import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
bootstrapApplication(AppComponent, {
providers: [
provideRouter(routes, withPreloading(PreloadAllModules))
]
});
What this shows:
- Eager routes —
home,about— in the main bundle - Lazy groups —
admin,shop— each in its own chunk - Each group uses
loadComponentfor its children — further lazy - Named chunks with
webpackChunkName PreloadAllModules— after initial load, admin and shop are preloaded
Bundling result:
Initial: main.js (~200 KB) + polyfills + styles
Lazy: admin.js, shop.js, and one chunk per child
Visiting / downloads only the initial bundle. Navigating to /admin downloads admin.js. The user never downloads /shop unless they visit it (or preloading fetches it in the background).
Why this shape: It’s the standard lazy loading setup. The shell is eager. Each section is lazy. Preloading warms the chunks for likely-navigated routes. That’s the pattern most production Angular apps use.
Complete Example Session
# ============================================
# PART 1: EAGER ROUTES
# ============================================
cat > app.routes.ts << 'EOF'
import { Routes } from '@angular/router';
import { HomeComponent } from './home.component';
export const routes: Routes = [
{ path: '', component: HomeComponent, pathMatch: 'full' }
];
EOF
# ============================================
# PART 2: LAZY COMPONENT
# ============================================
cat > lazy.routes.ts << 'EOF'
import { Routes } from '@angular/router';
export const routes: Routes = [
{ path: '', component: HomeComponent },
{
path: 'settings',
loadComponent: () =>
import('./settings.component').then(m => m.SettingsComponent)
}
];
import { HomeComponent } from './home.component';
EOF
npx tsc --noEmit lazy.routes.ts
# (no errors)
# ============================================
# PART 3: LAZY CHILDREN
# ============================================
cat > with-children.ts << 'EOF'
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: 'admin',
loadChildren: () =>
import('./admin.routes').then(m => m.adminRoutes)
}
];
EOF
cat > admin.routes.ts << 'EOF'
import { Routes } from '@angular/router';
export const adminRoutes: Routes = [
{ path: '', redirectTo: 'home', pathMatch: 'full' },
{
path: 'home',
loadComponent: () => import('./admin-home.component').then(m => m.AdminHomeComponent)
},
{
path: 'users',
loadComponent: () => import('./users.component').then(m => m.UsersComponent)
}
];
EOF
npx tsc --noEmit with-children.ts admin.routes.ts
# (no errors)
# ============================================
# PART 4: NAMED CHUNKS
# ============================================
cat > named.ts << 'EOF'
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: 'admin',
loadChildren: () =>
import(/* webpackChunkName: "admin" */ './admin.routes')
.then(m => m.adminRoutes)
}
];
EOF
npx tsc --noEmit named.ts
# (no errors)
# ============================================
# PART 5: PRELOADING
# ============================================
cat > preload.ts << 'EOF'
import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
import { bootstrapApplication } from '@angular/platform-browser';
import { AppComponent } from './app.component';
import { routes } from './app.routes';
bootstrapApplication(AppComponent, {
providers: [
provideRouter(routes, withPreloading(PreloadAllModules))
]
});
EOF
npx tsc --noEmit preload.ts
# (no errors)
# ============================================
# PART 6: GUARDED LAZY ROUTE
# ============================================
cat > guarded.ts << 'EOF'
import { Routes } from '@angular/router';
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
export const authGuard: CanActivateFn = () => {
const router = inject(Router);
const isLoggedIn = true;
if (isLoggedIn) return true;
return router.createUrlTree(['/login']);
};
export const routes: Routes = [
{
path: 'admin',
loadChildren: () => import('./admin.routes').then(m => m.adminRoutes),
canActivate: [authGuard]
}
];
EOF
npx tsc --noEmit guarded.ts
# (no errors)
# ============================================
# PART 7: CUSTOM PRELOADING
# ============================================
cat > custom-preload.ts << 'EOF'
import { Injectable } from '@angular/core';
import { PreloadingStrategy, Route } from '@angular/router';
import { Observable, of } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class SelectivePreloading implements PreloadingStrategy {
preload(route: Route, load: () => Observable<unknown>): Observable<unknown> {
return route.data?.['preload'] ? load() : of(null);
}
}
EOF
npx tsc --noEmit custom-preload.ts
# (no errors)
# ============================================
# PART 8: BUILD AND CHECK
# ============================================
# Run the build to see chunk output
# ng build
# The output shows:
# main.js
# polyfills.js
# styles.css
# admin.js (lazy)
# shop.js (lazy)
# ...
cat << 'EOF'
Lazy loading checklist:
[ ] Initial routes use `component` (eager)
[ ] Feature routes use `loadComponent` or `loadChildren`
[ ] No top-level imports of lazy code
[ ] Chunks have names (webpackChunkName)
[ ] Preloading strategy chosen
[ ] Build output verified: lazy chunks separate
[ ] Lazy routes tested in dev mode
[ ] No accidental eager imports
EOF
Quick Reference
Lazy Loading Forms
| Form | Loads |
|---|---|
loadComponent: () => import(...).then(m => m.C) | Single component |
loadChildren: () => import(...).then(m => m.routes) | Route group |
Syntax
// Component
loadComponent: () => import('./c.component').then(m => m.CComponent)
// Routes
loadChildren: () => import('./routes').then(m => m.routes)
Old vs New
| Old | New |
|---|---|
loadChildren: './m.module#M' | loadChildren: () => import('./m.module').then(m => m.M) |
loadChildren: () => import('./m.module').then(m => m.M) | Same |
What Gets Chunked
| Import type | Result |
|---|---|
Static import | Eager chunk |
Dynamic import() | Lazy chunk |
loadComponent | Lazy chunk |
loadChildren | Lazy chunk |
Bundling Output
| Chunk | Loads |
|---|---|
main.js | On first load |
polyfills.js | On first load |
styles.css | On first load |
feature.js | When route activates |
Preloading Strategies
| Strategy | Behavior |
|---|---|
NoPreloading (default) | No preloading |
PreloadAllModules | Load all after initial |
| Custom | Selected routes |
Enable Preloading
provideRouter(routes, withPreloading(PreloadAllModules))
Named Chunks
| Bundler | Syntax |
|---|---|
| Webpack | import(/* webpackChunkName: "name" */ '...') |
| Vite/esbuild | File name inference |
Common Patterns
| Pattern | Code |
|---|---|
| Lazy component | loadComponent: () => import(...).then(m => m.C) |
| Lazy group | loadChildren: () => import(...).then(m => m.routes) |
| Lazy with guard | loadChildren: ..., canActivate: [guard] |
| Lazy with resolver | loadChildren: ..., resolve: {...} |
| Lazy with data | loadChildren: ..., data: {...} |
| Lazy with params | path: 'users/:id', loadComponent: ... |
Guard Precedence
| Step | Runs |
|---|---|
| 1 | Eager guards on the parent |
| 2 | canActivate on the lazy route |
| 3 | Load the lazy chunk |
| 4 | Guards inside the lazy group |
| 5 | Resolvers |
Guards run before the chunk loads — denied routes never download.
Preloading Custom Strategy
class MyStrategy implements PreloadingStrategy {
preload(route: Route, load: () => Observable<unknown>) {
return route.data?.['preload'] ? load() : of(null);
}
}
Marking a Route for Preload
{
path: 'dashboard',
loadChildren: () => import(...).then(m => m.routes),
data: { preload: true }
}
Build Verification
| Check | Command |
|---|---|
| Chunk sizes | ng build |
| Bundle analysis | ng build --stats-json |
| Named chunks | Look for names in output |
Eager Import Trap
| Code | Problem |
|---|---|
import { C } from './lazy' in eager file | Pulls C into eager bundle |
loadChildren: () => import('./lazy') | Correct |
| Static import of lazy code | Undoes lazy loading |
Common Lazy Loading Shapes
| App section | Route |
|---|---|
| Admin | { path: 'admin', loadChildren: ... } |
| Shop | { path: 'shop', loadChildren: ... } |
| Settings | { path: 'settings', loadComponent: ... } |
| Profile | { path: 'profile', loadComponent: ... } |
Error Cases
| Error | Cause |
|---|---|
ChunkLoadError | Network failure or stale deploy |
Cannot find module | Wrong path in import |
| Lazy chunk not created | Static import used instead |
| Guard prevents chunk | Correct behavior |
| Slow first navigation | No preloading |
Dev vs Prod
| Mode | Chunk names |
|---|---|
| Dev | Readable, unminified |
| Prod | Hashed, minified |
When to Lazy Load
| Situation | Lazy? |
|---|---|
| Admin panel | ✅ |
| Shop | ✅ |
| Docs | ✅ |
| Settings | ✅ |
| Home page | ❌ (eager) |
| About page | ⚠️ (often eager) |
| Tiny app | ❌ |
Best Practices
✅ Do This:
// Use loadComponent for single components
{
path: 'settings',
loadComponent: () => import('./settings.component').then(m => m.SettingsComponent)
} // ✅
// Use loadChildren for route groups
{
path: 'admin',
loadChildren: () => import('./admin.routes').then(m => m.adminRoutes)
} // ✅
// Name chunks for readable output
import(/* webpackChunkName: "admin" */ './admin.routes') // ✅
// Keep the shell eager
{ path: '', component: HomeComponent } // ✅
// Use guards on lazy routes to avoid loading protected code
{
path: 'admin',
loadChildren: () => import('./admin.routes').then(m => m.adminRoutes),
canActivate: [authGuard]
} // ✅
// Preload likely-navigated routes
provideRouter(routes, withPreloading(PreloadAllModules)) // ✅
// Use custom preloading for selective loading
class SelectivePreloading implements PreloadingStrategy { } // ✅
// Verify chunk sizes after build
// ng build // ✅
// Lazy-load by feature, not by component
{ path: 'shop', loadChildren: ... } // ✅
// Keep lazy groups self-contained
// Don't share code with eager unnecessarily // ✅
❌ Don’t Do This:
// Don't statically import lazy code
import { AdminComponent } from './admin.component'; // ⚠️ eager // ⚠️
{ path: 'admin', loadComponent: () => import('./admin.component').then(...) }
// Don't use the old string syntax in new code
loadChildren: './admin/admin.module#AdminModule' // ⚠️ deprecated // ⚠️
// Don't lazy-load every route
// Small routes aren't worth a separate chunk // ⚠️
// Don't preload everything blindly
PreloadAllModules // ⚠️ bandwidth cost on public sites // ⚠️
// Don't share a lot of code between lazy chunks
// Duplicates or large common chunks // ⚠️
// Don't nest lazy loading deeply without reason
// { loadChildren → loadChildren → loadChildren } // ⚠️
// Don't forget the guard if the section is protected
// Protected code shouldn't load // ⚠️
// Don't assume lazy chunks are cached forever
// Browser cache eviction // ⚠️
// Don't use `import()` without the bundler recognizing it
// Bundlers need static paths // ⚠️
Common Pitfalls
| Pitfall | Problem | Solution |
|---|---|---|
| Static import of lazy code | Pulls into eager bundle | Remove top-level import |
String loadChildren | Deprecated | Use function form |
| Lazy loading everything | Overhead for small routes | Keep small routes eager |
| No preloading | Slow first lazy navigation | Add strategy |
| Preloading everything | Bandwidth cost | Use selective |
| Chunk too large | Slow first lazy load | Split into smaller groups |
| ChunkLoadError | Network or stale deploy | Reload, use hashes |
| Guard after chunk load | Wasted download | Guard before load |
| Nested lazy loading too deep | Complex, slow | Flatten |
| Lazy code shares eager code | Duplicates in bundle | Extract shared chunk |
Real-World Examples
1. Lazy component
{
path: 'settings',
loadComponent: () => import('./settings.component').then(m => m.SettingsComponent)
}
2. Lazy route group
{
path: 'admin',
loadChildren: () => import('./admin.routes').then(m => m.adminRoutes)
}
3. Named chunk
import(/* webpackChunkName: "admin" */ './admin.routes')
4. With guard
{
path: 'admin',
loadChildren: () => import('./admin.routes').then(m => m.adminRoutes),
canActivate: [authGuard]
}
5. With resolver
{
path: 'dashboard',
loadChildren: () => import('./dashboard.routes').then(m => m.routes),
resolve: { data: dashboardResolver }
}
6. With data
{
path: 'admin',
loadChildren: () => import('./admin.routes').then(m => m.adminRoutes),
data: { section: 'admin' }
}
7. With params
{
path: 'users/:id',
loadComponent: () => import('./user-detail.component').then(m => m.UserDetailComponent)
}
8. Preloading all
provideRouter(routes, withPreloading(PreloadAllModules))
9. Selective preloading
{
path: 'dashboard',
loadChildren: () => import('./dashboard.routes').then(m => m.dashboardRoutes),
data: { preload: true }
}
10. Custom strategy
class SelectivePreloading implements PreloadingStrategy {
preload(route: Route, load: () => Observable<unknown>): Observable<unknown> {
return route.data?.['preload'] ? load() : of(null);
}
}
11. Register strategy
provideRouter(routes, withPreloading(SelectivePreloading))
12. Lazy child routes
// admin.routes.ts
export const adminRoutes: Routes = [
{ path: '', loadComponent: () => import('./home.component').then(m => m.HomeComponent) },
{ path: 'users', loadComponent: () => import('./users.component').then(m => m.UsersComponent) }
];
13. Redirect in lazy group
export const adminRoutes: Routes = [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{ path: 'dashboard', loadComponent: () => import('./dashboard.component').then(m => m.DashboardComponent) }
];
14. Shared lazy chunk
// Both admin and shop lazy routes share a common module
// Bundler extracts into a shared chunk
15. Verify with build
ng build
# Check chunk output
16. Stats file
ng build --stats-json
# Analyze with webpack-bundle-analyzer
17. Handle ChunkLoadError
// Reload or show message on failed chunk load
// Usually a stale deploy — user has old index.html
18. Lazy with no parent component
{
path: 'admin',
loadChildren: () => import('./admin.routes').then(m => m.adminRoutes)
}
// No component — routes render in parent's outlet
19. Multiple lazy groups
export const routes: Routes = [
{ path: '', component: HomeComponent },
{ path: 'admin', loadChildren: () => import('./admin.routes').then(m => m.adminRoutes) },
{ path: 'shop', loadChildren: () => import('./shop.routes').then(m => m.shopRoutes) },
{ path: 'docs', loadChildren: () => import('./docs.routes').then(m => m.docsRoutes) }
];
20. Nested lazy loading
{
path: 'admin',
loadChildren: () => import('./admin.routes').then(m => m.adminRoutes)
}
// admin.routes.ts
export const adminRoutes: Routes = [
{
path: 'reports',
loadChildren: () => import('./reports.routes').then(m => m.reportsRoutes)
}
];
Visual: Eager vs Lazy
┌──────────────────────────────────────────────┐
│ EAGER (default) │
│ │
│ All imports at top: │
│ import { A } from './a'; │
│ import { B } from './b'; │
│ import { C } from './c'; │
│ │
│ → All bundled into main.js │
│ → Downloaded on first load │
│ │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ LAZY │
│ │
│ Only shell imported: │
│ loadChildren: () => import('./a') │
│ loadComponent: () => import('./b') │
│ │
│ → Each import becomes a chunk │
│ → Downloaded on route activation │
│ │
└──────────────────────────────────────────────┘
Visual: Chunk Loading
┌──────────────────────────────────────────────┐
│ First load: / │
│ │
│ Browser downloads: │
│ ├── main.js │
│ ├── polyfills.js │
│ └── styles.css │
│ │
│ Home component renders │
│ │
└──────────────────────────────────────────────┘
│
│ navigate to /admin
▼
┌──────────────────────────────────────────────┐
│ Router activates /admin │
│ │ │
│ ▼ │
│ Calls loadChildren() │
│ │ │
│ ▼ │
│ Browser downloads admin chunk │
│ │ │
│ ▼ │
│ Admin routes registered │
│ │ │
│ ▼ │
│ Admin component renders │
│ │
└──────────────────────────────────────────────┘
Visual: loadComponent vs loadChildren
┌──────────────────────────────────────────────┐
│ loadComponent │
│ │
│ { │
│ path: 'settings', │
│ loadComponent: () => import('./s').then(m => m.S)│
│ } │
│ │
│ → One component in the chunk │
│ → No child routes │
│ │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ loadChildren │
│ │
│ { │
│ path: 'admin', │
│ loadChildren: () => import('./r').then(m => m.routes)│
│ } │
│ │
│ → A Routes array in the chunk │
│ → Multiple child routes │
│ │
└──────────────────────────────────────────────┘
Visual: Bundling
┌──────────────────────────────────────────────┐
│ Source: │
│ │
│ app.routes.ts │
│ ├── import Home (static) │
│ ├── loadComponent Settings │
│ └── loadChildren Admin │
│ │
└──────────────────────────────────────────────┘
│
│ bundler
▼
┌──────────────────────────────────────────────┐
│ Chunks: │
│ │
│ main.js │
│ ├── App │
│ ├── Home │
│ └── router │
│ │
│ settings.js ← lazy │
│ admin.js ← lazy │
│ │
└──────────────────────────────────────────────┘
Visual: Eager Import Trap
┌──────────────────────────────────────────────┐
│ ❌ Wrong │
│ │
│ // app.routes.ts │
│ import { AdminComponent } from './admin'; │
│ │
│ { │
│ path: 'admin', │
│ loadComponent: () => import('./admin').then(m => m.AdminComponent)│
│ } │
│ │
│ Static import pulls AdminComponent │
│ into main.js — not lazy │
│ │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ ✅ Right │
│ │
│ // app.routes.ts │
│ // no import of AdminComponent │
│ │
│ { │
│ path: 'admin', │
│ loadComponent: () => import('./admin').then(m => m.AdminComponent)│
│ } │
│ │
│ Only the dynamic import references it │
│ Bundler creates a separate chunk │
│ │
└──────────────────────────────────────────────┘
Visual: Preloading
┌──────────────────────────────────────────────┐
│ Without preloading: │
│ │
│ Load main.js │
│ Wait │
│ User clicks Admin │
│ Download admin.js ← delay here │
│ Admin renders │
│ │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ With preloading: │
│ │
│ Load main.js │
│ Background: download admin.js, shop.js │
│ Wait (chunks cached) │
│ User clicks Admin │
│ Instant — chunk already cached │
│ Admin renders │
│ │
└──────────────────────────────────────────────┘
Visual: Chunk Loading Decision
┌──────────────────────────────────────────────┐
│ Route activated │
│ │ │
│ ▼ │
│ Guards run │
│ │ │
│ ├── Denied → chunk not downloaded │
│ │ │
│ └── Allowed │
│ │ │
│ ▼ │
│ Chunk already in cache? │
│ │ │
│ ├── Yes → instant │
│ │ │
│ └── No → download chunk │
│ │ │
│ ▼ │
│ Render component │
│ │
└──────────────────────────────────────────────┘
Visual: Shared Chunks
┌──────────────────────────────────────────────┐
│ admin.routes imports lodash │
│ shop.routes imports lodash │
│ │
└──────────────────────────────────────────────┘
│
│ bundler analyzes
▼
┌──────────────────────────────────────────────┐
│ lodash extracted into common chunk │
│ │
│ chunks: │
│ ├── common.js (lodash) │
│ ├── admin.js (admin code) │
│ └── shop.js (shop code) │
│ │
│ Both admin and shop load common.js │
│ │
└──────────────────────────────────────────────┘
Visual: When to Lazy Load
┌──────────────────────────────────────────────┐
│ Will most users visit this route? │
│ │ │
│ ├── Yes ──► Eager (or preload) │
│ │ │
│ └── No │
│ │ │
│ ├── Feature group? ──► loadChildren│
│ │ │
│ └── Single component? ──► loadComponent│
│ │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ Protected by auth? │
│ │ │
│ └── Yes ──► Lazy + guard │
│ (denied users don't load) │
│ │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ Large code? │
│ │ │
│ └── Yes ──► Lazy │
│ │
└──────────────────────────────────────────────┘
Visual: Build Output
┌──────────────────────────────────────────────┐
│ $ ng build │
│ │
│ Initial chunk files | Names | Raw size│
│ main.js | main | 250 kB │
│ polyfills.js | polyfills | 100 kB │
│ styles.css | styles | 5 kB │
│ │
│ Lazy chunk files | Names | Raw size│
│ chunk-ABC.js | admin | 80 kB │
│ chunk-DEF.js | shop | 120 kB │
│ chunk-GHI.js | settings | 20 kB │
│ │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ Initial download: │
│ 250 + 100 + 5 = 355 kB │
│ │
│ Full app if all visited: │
│ 355 + 80 + 120 + 20 = 575 kB │
│ │
│ Savings: 220 kB on initial load │
│ │
└──────────────────────────────────────────────┘
Visual: Decision Flow
┌──────────────────────────────────────────────┐
│ Should this route be lazy? │
│ │ │
│ ├── Home page? ──► Eager │
│ ├── Frequently visited? ──► Eager │
│ ├── Feature group? ──► loadChildren │
│ ├── Single component? ──► loadComponent│
│ └── Protected? ──► Lazy + guard │
│ │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ Enable preloading? │
│ │ │
│ ├── Internal app ──► Yes │
│ ├── Public site ──► No │
│ └── Selective ──► Custom strategy │
│ │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
│ Verify the bundle? │
│ │ │
│ ├── Check chunk sizes ──► ng build │
│ └── No eager imports ──► grep │
│ │
└──────────────────────────────────────────────┘
Summary
| Concept | Meaning |
|---|---|
| Lazy loading | Defer downloading code until needed |
loadComponent | Lazy-load a single component |
loadChildren | Lazy-load a route group |
import() | Dynamic import — creates a chunk |
| Chunk | Separate bundle for a lazy route |
| Preloading | Load chunks after initial in background |
PreloadAllModules | Preload every lazy chunk |
| Eager | Bundled in main.js — loads first |
| Chunk naming | webpackChunkName comment |
Key takeaways:
- Lazy loading splits code into chunks that load on demand
loadComponentlazy-loads a single componentloadChildrenlazy-loads a route group- Both use dynamic
import()— the bundler creates a chunk - The initial bundle contains only eager routes — everything else is deferred
- Remove static imports of lazy code — otherwise it’s eager
- Guards run before the chunk loads — denied routes don’t download
- Preloading downloads chunks in the background after initial load
PreloadAllModulesloads every chunk; custom strategies load selectively- Name chunks with
webpackChunkNamefor readable build output - Verify the bundle with
ng build— check that lazy chunks are separate - Don’t lazy-load small or frequently visited routes — the overhead isn’t worth it
- Lazy-load by feature, not by component
Remember: Lazy loading is about shipping less on the initial load. The shell stays eager; features load on demand. loadComponent for single components, loadChildren for groups. The bundler splits at every import() — so remove static imports of lazy code, or the split doesn’t happen. Add preloading when users will visit most routes. Verify the build output. Done right, the first paint is fast and the rest of the app loads as the user needs it.
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!