Lazy Loading & @defer
The fastest JavaScript is the JavaScript you never download. A user landing on your home page rarely needs the code for the admin dashboard, the analytics charts, or the rich-text editor buried three routes deep. Angular gives you two complementary tools to defer that cost: lazy-loaded routes, which split code at navigation boundaries, and the @defer block, which splits code at the template level and fetches it only when a trigger fires. Used together they shrink the initial bundle, improve Core Web Vitals like LCP and TBT, and keep your app interactive sooner.
Lazy-loaded routes
A lazy route is a route whose component (and everything it imports) lives in a separate chunk that the browser downloads only when the user navigates to it. With standalone components this is a one-liner using loadComponent, which returns a dynamic import():
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: '',
loadComponent: () => import('./home/home.component').then(m => m.HomeComponent),
},
{
path: 'reports',
loadComponent: () => import('./reports/reports.component').then(m => m.ReportsComponent),
},
{
path: 'admin',
loadChildren: () => import('./admin/admin.routes').then(m => m.ADMIN_ROUTES),
},
];
Use loadComponent for a single standalone component and loadChildren to lazy-load an entire group of routes (a feature area). The imported file is only fetched the first time that path activates; after that it is cached by the browser and the router.
You can confirm the split happened by inspecting the production build output — each lazy route becomes its own chunk:
Output:
Initial chunk files | Names | Raw size
main-7QJ4F2K.js | main | 138.42 kB
polyfills-RT5X.js | polyfills| 34.58 kB
Lazy chunk files | Names | Raw size
chunk-AB12CD.js | reports | 62.10 kB
chunk-EF34GH.js | admin | 48.73 kB
Prefetching lazy routes
Lazy loading trades a smaller initial bundle for a small delay on first navigation. You can hide that delay by warming up chunks ahead of time with a PreloadingStrategy. PreloadAllModules fetches every lazy bundle in the background once the app is idle:
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes, withPreloading(PreloadAllModules)),
],
};
PreloadAllModulesis great for small-to-medium apps. For large apps, implement a customPreloadingStrategy(or use a community quicklink strategy) that only preloads routes the user is likely to visit next — otherwise you re-download everything and lose the benefit.
The @defer block
Lazy routes split at navigation boundaries, but sometimes the heavy thing is a single widget inside an otherwise-light page — a chart, a comment thread, a map. @defer is a built-in control-flow block that code-splits that region of the template and loads it only when a trigger fires. Any component, directive, or pipe used exclusively inside the block is automatically moved into its own lazy chunk:
@defer (on viewport) {
<app-heavy-chart [data]="metrics()" />
} @placeholder (minimum 500ms) {
<div class="skeleton">Chart preview</div>
} @loading (after 100ms; minimum 1s) {
<app-spinner />
} @error {
<p class="error">Could not load the chart.</p>
}
The companion blocks control each phase: @placeholder shows before loading starts, @loading shows while the chunk downloads, and @error shows if the fetch fails. The minimum and after parameters prevent flicker on fast connections.
Defer triggers
The trigger decides when the chunk is fetched. Picking the right one matters: defer too aggressively and the content janks in late; defer too little and you keep the bundle bloated.
| Trigger | Fires when | Best for |
|---|---|---|
on idle (default) | Browser is idle (requestIdleCallback) | Below-the-fold, low-priority content |
on viewport | Element scrolls into view | Charts, images, sections further down |
on interaction | User clicks/keys the placeholder | Editors, modals opened by the user |
on hover | Pointer hovers the placeholder | Tooltips, preview cards |
on timer(2s) | After a delay | Non-critical secondary widgets |
when condition() | A signal/expression turns truthy | Content gated on app state |
You can combine triggers and add prefetch to fetch the chunk early while still rendering late — for example, prefetch on idle but only render on viewport:
@defer (on viewport; prefetch on idle) {
<app-comments [postId]="postId()" />
} @placeholder {
<div class="skeleton">Comments</div>
}
A dependency only leaves the main bundle if it is used exclusively inside
@defer. If the same component is also referenced eagerly elsewhere in the template, it stays in the initial chunk and deferring it saves nothing.
Lazy routes vs @defer
Both reduce initial load, but they operate at different granularities — use both.
| Lazy routes | @defer | |
|---|---|---|
| Split boundary | Whole route / feature | A region of one template |
| Triggered by | Navigation | Viewport, interaction, idle, timer, condition |
| Lifecycle UI | Route resolvers / spinners | @placeholder / @loading / @error |
| Granularity | Coarse | Fine |
Best Practices
- Make every feature route lazy with
loadComponent/loadChildren; keep only the shell and the initial route eager. - Reach for
@deferfor heavy, non-critical widgets inside an otherwise-light page (charts, maps, comment threads, editors). - Match the trigger to user intent:
on viewportfor below-the-fold content,on interactionfor user-initiated UI like modals and editors. - Add
prefetch on idleto defer rendering without paying a fetch delay when the trigger fires. - Always provide a
@placeholderwith stable dimensions to avoid layout shift, and useminimum/afterto prevent loading-state flicker. - Preload lazy routes with a
PreloadingStrategy, but use a targeted custom strategy rather thanPreloadAllModuleson large apps. - Verify your splits with
ng buildand read the lazy chunk report — if a chunk you expected is missing, a dependency is still referenced eagerly.