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 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.

YouShould I lazy-load EVERY non-home route, or only big ones?
YouLazy-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.

Leave a Comment

Your email stays private. Required fields are marked *

Leave a Comment

Your email stays private. Required fields are marked *