es = [
{
path: 'workspace',
loadComponent: () =>
import('./main-workspace/main-workspace.component')
.then(mod => mod.MainWorkspaceComponent)
},
{
path: 'analytics',
loadChildren: () =>
import('./analytics/analytics.routes')
.then(mod => mod.ANALYTICS_FEATURE_ROUTES)
},
{
path: '**',
loadComponent: () =>
import('./not-found/not-found.component')
.then(mod => mod.NotFoundComponent)
}
];
```typescript
// analytics/analytics.routes.ts
import { Routes } from '@angular/router';
import { AnalyticsLayoutComponent } from './analytics-layout.component';
import { RevenueViewComponent } from './revenue-view.component';
import { ForecastComponent } from './forecast.component';
export const ANALYTICS_FEATURE_ROUTES: Routes = [
{
path: '',
component: AnalyticsLayoutComponent,
children: [
{ path: '', component: RevenueViewComponent },
{
path: 'forecast',
loadComponent: () =>
import('./forecast.component')
.then(mod => mod.ForecastComponent)
}
]
}
];
Why this works: The router intercepts navigation requests and resolves the dynamic import only when the path matches. Webpack/Vite automatically generates separate chunks (analytics-analytics-routes.js, forecast_component.js). The initial bundle contains only the shell and eagerly required dependencies.
2. Template-Level Deferral with @defer
Route splitting handles navigation boundaries. @defer handles UI boundaries. Introduced in Angular 17, it compiles wrapped components into isolated chunks and defers their fetch until a specified trigger fires.
Architecture Rationale: Deferral operates at the template compilation stage. Angular's compiler extracts the deferred component and its transitive dependencies into a separate chunk. This is fundamentally different from *ngIf or @if, which only control DOM insertion. @defer controls network fetch timing.
// dashboard.component.ts
import { Component, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [CommonModule],
template: `
<header class="hero">
<h1>System Overview</h1>
<p>Real-time metrics and status indicators.</p>
</header>
<section class="metrics-grid">
@for (metric of activeMetrics; track metric.id) {
<app-metric-card [data]="metric" />
}
</section>
@defer (on viewport; prefetch on idle) {
<app-interactive-chart
[dataset]="historicalData"
[config]="chartOptions"
/>
} @placeholder {
<div class="skeleton-chart" aria-label="Loading chart"></div>
} @loading (minimum 400ms) {
<div class="progress-indicator" role="status">Fetching analytics engine...</div>
} @error {
<app-error-banner message="Chart rendering failed. Retry?" />
}
`
})
export class DashboardComponent {
activeMetrics = [/* ... */];
historicalData = [/* ... */];
chartOptions = { /* ... */ };
}
Trigger Selection Strategy:
on viewport: Best for below-the-fold content. Fetches when the element enters the scroll container.
on idle: Ideal for non-urgent background tasks. Fires when the main thread is free.
on interaction: Use for modals, drawers, or expandable panels. Fetches on click/focus.
when condition: Ties deferral to application state (e.g., feature flags, user permissions).
The prefetch modifier is critical for production. It tells Angular to download the chunk in the background when the trigger condition is met, but not render it until explicitly needed. This eliminates perceived loading delays.
3. Intelligent Preloading Strategies
PreloadAllModules fetches every lazy chunk after the initial render. While simple, it wastes bandwidth on routes users never visit. A metadata-driven strategy provides granular control.
// strategic-preloader.service.ts
import { Injectable } from '@angular/core';
import { PreloadingStrategy, Route } from '@angular/router';
import { Observable, of } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class StrategicPreloaderService implements PreloadingStrategy {
preload(route: Route, load: () => Observable<any>): Observable<any> {
const shouldPrefetch = route.data?.['priority'] === 'high';
const isAuthorized = route.data?.['requiresAuth'] !== true;
return (shouldPrefetch && isAuthorized) ? load() : of(null);
}
}
// app.config.ts
import { provideRouter, withPreloading, withRouterConfig } from '@angular/router';
import { StrategicPreloaderService } from './strategic-preloader.service';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(
APP_ROUTES,
withPreloading(StrategicPreloaderService),
withRouterConfig({
initialNavigation: 'enabledBlocking',
scrollPositionRestoration: 'top'
})
)
]
};
Why this matters: By tagging routes with data: { priority: 'high' }, you preload only the navigation paths users are statistically likely to take next. The enabledBlocking configuration ensures SSR hydration completes before client-side routing activates, preventing content flashes during server-to-client handoff.
Pitfall Guide
1. The Phantom Import Breaks Chunk Boundaries
Explanation: A single static import { HeavyComponent } in an eagerly loaded file pulls the entire dependency tree into the main bundle, nullifying route-level splits.
Fix: Audit imports rigorously. Use ng build --stats-json and source-map-explorer to verify chunk boundaries. Replace static imports with dynamic ones or move shared utilities into a dedicated shared/ folder that is explicitly excluded from lazy chunks.
2. Over-Deferring Critical Path UI
Explanation: Wrapping above-the-fold content in @defer (on viewport) delays rendering until scroll or idle, increasing perceived load time.
Fix: Reserve deferral for secondary content, heavy visualizations, or optional panels. Use on immediate or standard rendering for hero sections, navigation, and primary CTAs.
3. Indiscriminate Preloading Wastes Bandwidth
Explanation: PreloadAllModules fetches chunks for admin panels, settings, and rarely used features, consuming mobile data and competing with critical resources.
Fix: Implement a custom preloader that respects route metadata, user roles, and network conditions. Combine with navigator.connection.effectiveType to disable preloading on slow-2g or 2g.
4. SSR Hydration Mismatches with @defer
Explanation: Server-side rendering outputs the placeholder or loading state, but the client expects the deferred component. If hydration runs before the chunk loads, Angular throws a DOM mismatch error.
Fix: Use withDomEventDisabled() in provideClientHydration() when using @defer. Alternatively, ensure deferred blocks only render client-side by wrapping them in @if (isPlatformBrowser(platformId)) during SSR transitions.
5. Missing Chunk Naming Conventions
Explanation: Default chunk names (chunk-abc123.js) make debugging, caching, and CDN invalidation difficult.
Fix: Configure the build tool to assign meaningful names. In angular.json, use namedChunks: true. For Vite/Webpack, configure output.chunkFilename to include route or feature identifiers.
6. Bundle Budget Misconfiguration
Explanation: Setting budgets too low causes false CI failures; setting them too high allows regression.
Fix: Align budgets with actual user constraints. Start with initial: 450kb, any: 200kb, and anyComponentStyle: 6kb. Use warning thresholds during migration, then switch to error once stable.
7. Ignoring Third-Party Library Bundling
Explanation: Importing entire UI kits or utility libraries (e.g., import * as _ from 'lodash') bloats chunks regardless of lazy loading.
Fix: Use tree-shakeable alternatives (lodash-es, date-fns, @angular/material individual imports). Configure optimization.splitChunks to isolate vendor dependencies into a shared chunk that browsers can cache independently.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Small marketing site (< 5 routes) | Eager loading + @defer for heavy sections | Routing overhead outweighs benefits; deferral handles payload spikes | Low infrastructure, minimal build config |
| Medium SaaS dashboard (10-30 routes) | Route-level loadChildren + PreloadAllModules | Predictable navigation patterns; background fetch improves perceived speed | Moderate bandwidth, high TTI gain |
| Enterprise platform (50+ routes, role-based access) | Custom preloader + @defer + chunk naming | Prevents unauthorized chunk fetches; optimizes for diverse user journeys | Higher dev overhead, significant bandwidth savings |
| Mobile-first / PWA | @defer (on viewport) + network-aware preloader | Respects data caps and CPU constraints; defers non-critical UI | Lower data costs, improved retention |
Configuration Template
// angular.json (build configuration snippet)
{
"projects": {
"app": {
"architect": {
"build": {
"options": {
"optimization": true,
"outputHashing": "all",
"sourceMap": true,
"namedChunks": true,
"budgets": [
{
"type": "initial",
"maximumWarning": "400kb",
"maximumError": "500kb"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kb",
"maximumError": "8kb"
},
{
"type": "any",
"maximumWarning": "250kb",
"maximumError": "350kb"
}
]
},
"configurations": {
"production": {
"fileReplacements": [
{
"replace": "src/environments/environment.ts",
"with": "src/environments/environment.prod.ts"
}
]
}
}
}
}
}
}
}
// app.config.ts (complete router & hydration setup)
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter, withPreloading, withRouterConfig } from '@angular/router';
import { provideClientHydration } from '@angular/platform-browser';
import { APP_ROUTES } from './app.routes';
import { StrategicPreloaderService } from './strategic-preloader.service';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(
APP_ROUTES,
withPreloading(StrategicPreloaderService),
withRouterConfig({
initialNavigation: 'enabledBlocking',
scrollPositionRestoration: 'top',
onSameUrlNavigation: 'reload'
})
),
provideClientHydration({
eventReplay: true,
debug: false
})
]
};
Quick Start Guide
- Audit Current Splits: Run
ng build --stats-json followed by npx source-map-explorer dist/app/browser/*.js. Identify components or libraries bleeding into the initial chunk.
- Convert Feature Routes: Replace static component imports in your route configuration with
loadComponent or loadChildren pointing to standalone route arrays. Verify each route generates a separate network request in DevTools.
- Implement Deferral: Locate heavy UI blocks (charts, maps, rich editors) and wrap them in
@defer (on viewport; prefetch on idle). Add @placeholder and @loading states to maintain layout stability.
- Configure Preloading: Replace
PreloadAllModules with a custom strategy that checks route.data?.['priority']. Tag high-traffic routes accordingly and disable preloading for admin or rarely accessed features.
- Enforce Budgets: Add the
budgets array to angular.json. Set warnings at 80% of your target and errors at 100%. Integrate the build step into your CI pipeline to block merges that exceed thresholds.
Lazy loading is not a one-time configuration. It is a continuous architectural discipline. As features grow, dependency graphs shift, and user behavior evolves, your chunking strategy must adapt. Treat bundle boundaries as first-class contracts, audit delivery patterns quarterly, and prioritize context-aware fetching over blanket optimization. The result is an application that scales gracefully, respects user constraints, and maintains competitive performance regardless of feature complexity.