Code splitting is the single biggest lever for first-page performance in any non-trivial Angular app. Two mechanisms shape it: route-level lazy loading with loadComponent / loadChildren, and template-level lazy loading with @defer blocks. This lesson closes Module 5 by walking both — when to use which, the prefetch triggers, and the performance trade-offs.
This is lesson 5.6 of the Angular Tutorial, the final lesson of Module 5. After this lesson you can ship apps where the first-page bundle is tens of kilobytes regardless of how many routes the app has.
Route-level: loadComponent
The modern way to lazy-load a single route’s component:
export const routes: Routes = [
{ path: 'home', component: HomePage }, // eager
{ path: 'admin', loadComponent: () => import('./admin').then(m => m.AdminPage) }, // lazy
];
The loadComponent returns a Promise — esbuild splits admin.ts into its own chunk that only downloads when the user navigates to /admin. The home page bundle never includes admin code.
The shorthand for default exports:
// admin.ts
export default class AdminPage { /* ... */ }
// routes
{ path: 'admin', loadComponent: () => import('./admin') }
For most cases the explicit .then(m => m.X) is clearer than relying on the default-export convention.
Route-level: loadChildren (for sub-trees)
When a whole feature area has its own routes, lazy-load the routes themselves:
// app.routes.ts
export const routes: Routes = [
{ path: '', component: HomePage },
{ path: 'admin', loadChildren: () => import('./admin/routes').then(m => m.adminRoutes) },
];
// admin/routes.ts
import { Routes } from '@angular/router';
import { AdminShell } from './admin-shell';
import { UsersPage } from './users';
export const adminRoutes: Routes = [
{ path: '', component: AdminShell, children: [
{ path: 'users', component: UsersPage },
{ path: 'settings', component: SettingsPage },
]},
];
The entire admin folder — its routes, its components, its services — ships as one chunk loaded when the user first enters /admin. Subsequent navigation within /admin/* is instant; the chunk is cached.
loadChildren is the right call when an area has multiple routes that share services. loadComponent is right when each route is independent.
Template-level: @defer blocks
v17 introduced @defer for lazy-loading parts of a template. Wrap any markup that doesn’t need to be in the first paint:
<h1>Article title</h1>
<p>Above-the-fold content...</p>
@defer (on viewport) {
<app-comments />
} @placeholder {
<p>Comments load when you scroll down.</p>
} @loading (after 100ms) {
<p>Loading comments...</p>
} @error {
<p>Failed to load comments.</p>
}
<footer>...</footer>
Four blocks define the full lifecycle:
| Block | When it shows |
|---|---|
@placeholder |
Before the deferred content has been requested |
@loading |
While the chunk is downloading (with optional after delay + minimum duration) |
@error |
If the chunk fails to load |
(main @defer) |
After the chunk loads |
The <app-comments /> component is in its OWN chunk — separate from the article’s bundle. The chunk downloads on the configured trigger.
@defer triggers
Every @defer needs a trigger that decides WHEN to load:
| Trigger | When it fires |
|---|---|
on idle |
(Default) When the browser is idle (requestIdleCallback) |
on viewport |
When the placeholder scrolls into view |
on interaction |
On click / keydown / pointerdown on the placeholder |
on hover |
On mouseenter / focusin on the placeholder |
on immediate |
As soon as the surrounding view renders |
on timer(Ns) |
After N seconds |
when <expression> |
When a signal-based condition becomes truthy |
Combine triggers with or:
@defer (on viewport; on idle; on timer(5s)) {
<app-comments />
}
Whichever fires first wins. The component loads at the earliest opportunity.
prefetch triggers — load early, render later
Separate the LOAD trigger from the SHOW trigger:
@defer (on interaction; prefetch on viewport) {
<app-comments />
}
When comments scroll into view: chunk downloads in the background. When the user CLICKS to expand: render immediately (already in memory). Best-of-both — instant interaction, no upfront bandwidth cost.
Prefetch is the secret sauce for fast-feeling apps. Use it whenever the interaction trigger differs from the prefetch trigger.
Hydration-aware deferring (SSR)
For server-rendered apps (Module 9), @defer (hydrate on ...) lazy-hydrates client-side JavaScript:
@defer (hydrate on viewport) {
<app-comments />
}
The component renders on the SERVER (so the markup is there for SEO and first paint). The JavaScript hydration — wiring up event listeners and signal subscriptions — is deferred until the trigger fires. Subjectively: the page is faster because non-critical interactivity loads later.
Lesson 9.5 covers incremental hydration in depth.
When to lazy-load vs eager
Guidelines:
| Pattern | Heuristic |
|---|---|
| Lazy-load every secondary route | Settings, admin, profile, help — anywhere the user isn’t on their first visit |
| Eager-load the home page route | Lazy-loading the route the user lands on adds a round-trip with no benefit |
| Lazy-load any component over ~50 KB minified | The threshold where lazy is clearly worth it |
@defer content below the fold |
Comments, related posts, video players — anything not in the first viewport |
@defer (prefetch on hover) |
For interactions where you can predict the user’s next move |
A typical SPA in 2026 has 80% of its routes lazy-loaded. The main bundle is the shell + home page; everything else loads on demand.
Naming chunks for debugging
esbuild generates chunk names automatically (chunk-A7F3B2.js). For more diagnostic-friendly names in dev builds, set output.chunkNames in angular.json or use a webpack-magic-comment-style hint:
loadComponent: () => import(/* @vite-ignore */ './admin').then(m => m.AdminPage)
In production these names don’t matter (everything is hashed), but in dev source maps they make profiling easier.
Measuring impact
A typical 2026 Angular app’s bundle breakdown:
main.js 45 KB ← framework + home page + shell
chunk-admin.js 38 KB ← /admin/* — loaded on first navigation
chunk-help.js 12 KB ← /help/* — loaded on first navigation
chunk-comments.js 18 KB ← @defer (on viewport)
styles.css 8 KB
First-page load: only main.js + styles.css = 53 KB compressed. The rest loads as the user navigates and scrolls.
Without lazy loading, the same app would be ~120 KB on first load. Each KB shaved is measurable in Lighthouse / Core Web Vitals (Module 8 covers performance in depth).
Common gotchas
| Symptom | Cause | Fix |
|---|---|---|
| Lazy chunk doesn’t split — bundled into main | The component is imported elsewhere in eager code | Audit imports; the route’s dynamic import must be the ONLY import |
@defer content renders eagerly |
Triggers missing OR @placeholder block missing |
Both are required (the placeholder defines the trigger target) |
| Service in lazy chunk creates new instance | Service is provided in the lazy route’s providers |
Move to providedIn: 'root' for app-wide singleton, or accept the per-area instance |
| Prefetch fires too aggressively | on idle fires very quickly |
Use on hover or on viewport for more targeted prefetching |
| Tests fail with “loadComponent timeout” | Test environment doesn’t load chunks | Use RouterTestingModule.withRoutes(...) with the components imported eagerly in tests |
What’s next
This closes Module 5. Module 6 picks up HttpClient — the API layer most resolvers and @defer chunks depend on. Module 7 returns to RxJS in modern Angular. Module 8 covers CSR performance in depth (including bundle analysis — the metrics for understanding whether your lazy-loading actually pays off).
Try it yourself
A tiny app with one lazy route and a deferred section:
import { Component } from '@angular/core';
import { provideRouter, RouterOutlet, RouterLink } from '@angular/router';
import { bootstrapApplication } from '@angular/platform-browser';
// home.component.ts
@Component({
template: `
<h1>Home</h1>
@defer (on viewport) {
<app-comments />
} @placeholder {
<div style="height: 200px;">Scroll for comments</div>
}
`,
})
export class HomePage {}
// app.routes.ts
export const routes: Routes = [
{ path: '', component: HomePage },
{ path: 'admin', loadComponent: () => import('./admin').then(m => m.AdminPage) },
];
@Component({
selector: 'app-root',
imports: [RouterOutlet, RouterLink],
template: `<a routerLink="/">Home</a> | <a routerLink="/admin">Admin</a><router-outlet />`,
})
class App {}
bootstrapApplication(App, { providers: [provideRouter(routes)] });
Open the browser’s Network tab. Refresh on /: see main.js + some chunks. Click /admin: see a new chunk download. Scroll the home page until <app-comments /> appears: see another chunk download. Three separate fetches, three separate bundles.
Module 5 is complete. Module 6 picks up HttpClient — the foundation for the rest of the data-fetching story.

