Angular Lazy Loading and @defer: Route-Level and Template-Level Code Splitting [2026]
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 by separating them with ; (evaluated as 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 />
}
It needs SSR with hydration: in v22, provideClientHydration() enables incremental hydration by default (the old withIncrementalHydration() feature is deprecated). Lesson 9.5 covers it in depth. 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, enable the namedChunks build option in angular.json (it is on in the default development configuration):
"configurations": {
"development": { "namedChunks": true, "sourceMap": true }
}
In production these names don't matter (everything is hashed), but in dev source maps they make profiling easier.
Measuring impact #
An illustrative bundle breakdown for a mid-sized Angular app:
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 is not split into its own chunk |
The deferred component is not standalone, or is also referenced outside the @defer block in the same file (including viewChild queries) |
Keep deferred dependencies standalone and referenced only inside the block |
on viewport / on interaction / on hover never fires |
No trigger target | Add a @placeholder with a single root element, or reference an element: on viewport(trigger) |
| 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 |
| Lazy route is awkward to test | Navigation and chunk loading are async | Use provideRouter(routes) with RouterTestingHarness and await harness.navigateByUrl(...) (RouterTestingModule is deprecated) |
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, Routes } from '@angular/router';
import { bootstrapApplication } from '@angular/platform-browser';
import { CommentsComponent } from './comments'; // only used inside @defer, so it gets its own chunk
// home.component.ts
@Component({
imports: [CommentsComponent],
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.
get_best_practicesLazy-load every secondary route unless you have a specific reason not to. The cost is one network round-trip on first navigation to that route; the benefit is a smaller main bundle that ALL users pay for on first paint. Even small routes are worth lazy-loading because they often grow over time, and once lazy, they stay lazy as they grow. The exception: routes the user lands on directly (deep-linked landing pages) — those benefit from eager loading because lazy adds latency to their first paint.Module 5 is complete. Module 6 picks up HttpClient — the foundation for the rest of the data-fetching story.
Up next in Angular
More from this topic
Enjoyed this article?
Get new Angular tutorials delivered. No spam — just code-first articles when they ship.


