The first thing I do on any Angular codebase is run a bundle analysis. The pattern I look for is a familiar one: a user opening the first screen downloads code for features and flow steps they may never reach. Lazy loading is the fix, and it can go in the boring way: one feature route at a time, through normal reviewed pull requests. No heroics, no big-bang refactor, and still the cheapest performance win I know. The code samples below use a loan-application domain purely as an illustration; none of them come from an employer's codebase.
Why the Default Angular Setup Hurts You
Angular's default build puts everything into one main chunk. Every component that is imported anywhere in your eagerly-loaded module tree ends up in that chunk. A reporting module imported in app.module.ts or app.config.ts drags in every chart library, every table component, and every service that reporting needs. The user requesting the login screen pays for all of it.
Lazy loading moves route-specific code into separate chunks that are fetched on demand. A user who never navigates to reports never downloads the reporting code. First Contentful Paint improves. Time to Interactive improves. Lighthouse scores improve. And the actual user experience improves on the connections that matter, which are never the fibre connections developers test on.
loadComponent for Single Route Components
With standalone components, this is three lines of code per route.
// app.routes.ts
export const routes: Routes = [
{
path: '',
loadComponent: () =>
import('./features/dashboard/dashboard.component')
.then(m => m.DashboardComponent)
},
{
path: 'applications',
canActivate: [authGuard],
loadComponent: () =>
import('./features/applications/applications.component')
.then(m => m.ApplicationsComponent)
},
{
path: 'reports',
canActivate: [authGuard, roleGuard('analyst')],
loadComponent: () =>
import('./features/reports/reports.component')
.then(m => m.ReportsComponent)
}
];The dynamic import is the boundary. The Angular compiler creates a separate chunk for anything reachable from that import. If ReportsComponent imports a 400KB charting library and a 200KB PDF renderer, those stay out of the main bundle.
loadChildren for Feature Route Trees
For features with multiple nested routes, loadChildren is the right call. It defers an entire route file into its own chunk, along with everything that file imports statically. Child routes can still use loadComponent to split further, as below.
// app.routes.ts
export const routes: Routes = [
{
path: 'lending',
canActivate: [authGuard],
loadChildren: () =>
import('./features/lending/lending.routes')
.then(m => m.LENDING_ROUTES)
}
];
// features/lending/lending.routes.ts
export const LENDING_ROUTES: Routes = [
{
path: '',
loadComponent: () =>
import('./application-list/application-list.component')
.then(m => m.ApplicationListComponent)
},
{
path: ':id',
loadComponent: () =>
import('./application-detail/application-detail.component')
.then(m => m.ApplicationDetailComponent)
},
{
path: ':id/documents',
loadComponent: () =>
import('./document-upload/document-upload.component')
.then(m => m.DocumentUploadComponent)
}
];Preloading: Lazy Without the Navigation Delay
The downside of lazy loading is that the first navigation to a lazy route triggers a network fetch, which causes a brief delay. Preloading strategies close this gap by loading chunks in the background after the app boots, without blocking first render.
// app.config.ts
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes, withPreloading(PreloadAllModules))
]
};PreloadAllModules works for most apps. For larger apps where bandwidth matters, a custom selective preloader loads only high-priority routes.
@Injectable({ providedIn: 'root' })
export class PriorityPreloadingStrategy implements PreloadingStrategy {
preload(route: Route, load: () => Observable<unknown>): Observable<unknown> {
return route.data?.['preload'] === true ? load() : of(null);
}
}
// Mark routes that should preload
{
path: 'applications',
data: { preload: true },
loadChildren: () =>
import('./features/applications/applications.routes')
.then(m => m.APPLICATIONS_ROUTES)
}How to Audit Your Current Bundle
- Run ng build --stats-json to generate a stats.json file (with the default application builder it is an esbuild metafile that esbuild's bundle analyzer can read)
- Build with --source-map, then run npx source-map-explorer dist/your-app/browser/main-*.js to visualize what is inside
- Look for any library over 50KB that is not needed on the initial screen
- Look for feature components that are imported directly in app.config.ts or eagerly-loaded modules
- Identify routes that a majority of users never visit. Those are the first candidates
Mistakes That Defeat Lazy Loading
- Importing a lazy component directly anywhere in the eagerly-loaded tree: the import defeats the lazy boundary
- providedIn: 'any' on a service a lazy route uses: this one is documented behaviour rather than a defect, and it still catches people out. You get one instance per lazy injector, not a singleton. Use 'root' when the state has to be shared across features
- Third-party libraries imported in shared modules that are eagerly loaded: move them to the feature that needs them
- Skipping preloading and accepting that every first navigation to a lazy route has a visible delay
Lazy loading is not a micro-optimization. It is often the fastest path from a slow app to a fast one, measured in hours of work and seconds of improvement.
Run the bundle analysis first. The numbers will tell you exactly where to start. In every app I have audited, two or three routes account for the bulk of the unnecessary main bundle weight. Fix those and measure again. The effort-to-impact ratio on lazy loading beats every other performance technique I know.
References
- Angular route loading strategies. https://angular.dev/guide/routing/loading-strategies
- Angular preloading strategies. https://angular.dev/api/router/PreloadingStrategy
- source-map-explorer. https://www.npmjs.com/package/source-map-explorer
- web.dev code splitting guide. https://web.dev/articles/code-splitting-suspense