The Problem of a Monolithic Bundle
In a large Angular application with several feature modules, the primary JavaScript bundle can quickly balloon in size. A practical solution to this is the lazy module pattern. In this article, we'll explore how to implement lazy loading, set up built-in preloading, and design a custom preload strategy that fits your application's specific needs.
Initial Setup: An Example Payment Application
Consider a payment application structured around four distinct modules: Dashboard, MoneyTransfer, Wallet, and Activity. Each module encapsulates its own set of components related to its business domain. The following code snippet illustrates the standard layout of the ActivityModule, which follows the same pattern as the other feature modules.
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivityComponent } from './activity.component';
import { ActivityRouterModule } from './activity.routing.module';
@NgModule({
imports: [
CommonModule
],
declarations: [ActivityComponent]
})
export class ActivityModule { }
In this initial configuration, all four modules are imported and registered within the app.module file. To enable navigation, the root router is configured, mapping URL paths to specific components so they can be rendered within the <router-outlet></router-outlet> directive.
This is achieved by using the RouterModule.forRoot method, which takes a route configuration array that includes the path and the component for each route.
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';
import { NotFoundComponent } from './not-found/not-found.component';
import {MoneyTransferModule} from "./moneytransfer/moneytransfer.module";
import {DashboardModule} from "./dashboard/dashboard.module";
import {WalletModule} from "./wallet/wallet.module";
import {ActivityModule} from "./activity/activity.module";
import {RouterModule, Routes} from "@angular/router";
import {WalletComponent} from "./wallet/wallet.component";
import {ActivityComponent} from "./activity/activity.component";
import {DashboardComponent} from "./dashboard/dashboard.component";
import {MoneyTransferComponent} from "./moneytransfer/moneytransfer.component";
const routes: Routes = [
{ path: '', redirectTo: '/dashboard', pathMatch: 'full' },
{ path: 'wallet', component: WalletComponent },
{ path: 'activity', component: ActivityComponent },
{ path: 'dashboard', component: DashboardComponent },
{ path: 'money', component: MoneyTransferComponent },
{ path: '**', component: NotFoundComponent },
]
@NgModule({
declarations: [AppComponent, NotFoundComponent],
imports: [BrowserModule, MoneyTransferModule, DashboardModule, WalletModule, ActivityModule,
RouterModule.forRoot(routes)
],
bootstrap: [AppComponent],
})
export class AppModule {}
This setup makes the app functional. But let's examine the resulting output and its performance implications.
For more details, check out the official guide on Routing in Angular.
Analyzing the Initial Load
With this monolithic approach, the Angular compiler processes all modules, merges them, and packages them into a single, large main.js bundle. This means the entire application, including all its modules, is downloaded at once, which can significantly slow down the initial page load. The user is forced to wait for the entire bundle to download before they can interact with anything.
This setup prompts a few important questions:
- We are loading a
36.0kBbundle just to view theDashboardarea. - Why should the browser download code for modules we haven't even visited yet?
- Why penalize the user with a long waiting time for modules they may not need?
The solution to these problems is Angular's Lazy Loading Modules feature.
Implementing Lazy Loading
The first step is to separate the routing logic and module loading from the main app.module. We will create a dedicated module, app.routing.module, which will be solely responsible for defining the application's routes and loading other feature modules.
This routing module uses the loadChildren property in its route definitions. This property signals to Angular to only import and load the module when the user actually navigates to that specific route.
const routes: Routes = [
{ path: '', redirectTo: '/dashboard', pathMatch: 'full' },
{
path: 'wallet',
loadChildren: () =>
import('./wallet/wallet.module').then((m) => m.WalletModule),
},
{
path: 'activity',
loadChildren: () =>
import('./activity/activity.module').then((m) => m.ActivityModule),
},
{
path: 'dashboard',
loadChildren: () =>
import('./dashboard/dashboard.module').then((m) => m.DashboardModule),
},
{
path: 'money',
loadChildren: () =>
import('./moneytransfer/moneytransfer.module').then(
(m) => m.MoneyTransferModule
),
},
{ path: '**', component: NotFoundComponent },
];
@NgModule({
imports: [
RouterModule.forRoot(routes)
],
exports: [RouterModule],
})
export class AppRoutingModule {}
Next, each feature module must be configured to provide its own routing configuration. This is done by creating a child routing module that uses the RouterModule.forChild() method.
The following example shows this process for the
ActivityModule, but it's identical for all other feature modules.
The ActivityRouterModule is responsible for importing the RouterModule and configuring it with the routes specific to the Activity feature, including the path and the component to be rendered.
The definition of
ActivityRouterModulelooks like this.
import { NgModule } from '@angular/core';
import { RouterModule } from '@angular/router';
import { ActivityComponent } from './activity.component';
const routes = [ { path: 'activity', component: ActivityComponent }]
@NgModule({
imports: [ RouterModule.forChild(routes) ],
exports: [ RouterModule],
})
export class ActivityRouterModule { }
This ActivityRouterModule is then imported by the ActivityModule to make its routing configuration available.
Finally, we clean up the app.module by removing all direct references to the feature modules. The only module we need to import is the new app.routing.module.
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { AppComponent } from './app.component';
import { NotFoundComponent } from './not-found/not-found.component';
import {AppRoutingModule} from "./app-routing.module";
@NgModule({
declarations: [AppComponent, NotFoundComponent],
imports: [
AppRoutingModule,
BrowserModule
],
bootstrap: [AppComponent],
})
export class AppModule {}
This change ensures that only the minimal necessary code is downloaded at startup. Feature modules are then fetched individually as the user navigates to their respective paths.
Benefits of Lazy Loading
- The initial bundle,
main.js, is significantly reduced from 32KB down to a much smaller 12.8 KB. - All other feature modules are now loaded on demand, greatly improving the initial load time.
The Trade-off
However, this benefit comes with a new drawback: "load-time latency". When a user clicks to navigate to a module that hasn't been loaded yet, the browser must first fetch it from the server, resulting in a noticeable delay before the module's content appears.
Preloading Modules for Instant Navigation
To resolve this trade-off, Angular offers the preloadingStrategy. This feature allows the application to fetch all remaining lazy-loaded modules in the background as soon as the initial bundle has been loaded. This ensures that when a user navigates to a new route, the module is already available, providing a faster, more seamless experience.
This strategy downloads the modules asynchronously as soon as the app is stable, making navigation between modules nearly instantaneous. It's configured by passing an options object to the forRoot method of the root RouterModule.
The value PreloadAllModules, which is provided by the Angular router, tells the router to preload every lazy-loaded module.
@NgModule({
imports: [
RouterModule.forRoot(routes, {
preloadingStrategy: PreloadAllModules,
}),
],
exports: [RouterModule],
})
export class AppRoutingModule {}
With this configuration in place, the application combines the benefits of lazy loading—a small initial footprint—with the benefit of having all remaining modules fetched in the background for fast, subsequent navigation.
Dive deeper into the
PreloadingStrategyAPI at https://angular.io/api/router/PreloadingStrategy.
Building a Custom Preload Strategy
With PreloadAllModules, every lazy-loaded module gets fetched immediately. But there are plenty of scenarios where you'd rather hand-pick which modules should be preloaded — or even control the timing of that load.
To accomplish this, create a class that implements Angular's built-in PreloadingStrategy interface.
The required method is preload(), which decides per route whether to fetch the module or skip it.
To tell your strategy which routes to preload, you can use a custom flag in the route's data object.
Inside the load function, check if the preload flag exists and is truthy. If so, invoke the load function; otherwise, return an empty observable to do nothing.
import { Injectable } from '@angular/core';
import { PreloadingStrategy, Route } from '@angular/router';
import { Observable, of } from 'rxjs';
@Injectable({
providedIn: 'root',
})
export class ModuleLoadingStrategyService implements PreloadingStrategy {
preload(route: Route, load: () => Observable<any>): Observable<any> {
if (route.data && route.data['preload']) {
return load();
}
return of(null);
}
}
Next, swap the built-in strategy with your custom one—replace PreLoadAllModules with ModuleLoadingStrategyService.
import {ModuleLoadingStrategyService} from "./config/module.loading.strategy";
@NgModule({
imports: [
RouterModule.forRoot(routes, {
preloadingStrategy: ModuleLoadingStrategyService,
})
],
exports: [RouterModule],
})
Finally, add the preload property into the data object of the routes you want to preload—take the wallet module as an example.
{
path: 'wallet',
loadChildren: () =>
import('./wallet/wallet.module').then((m) => m.WalletModule),
data: { preload: true },
},
That's it—we now have lazy loading with a custom strategy that fetches only the modules we explicitly select.
Wrapping Up
Throughout this guide, we've covered how to set up lazy loading, apply preload strategies to eager-load modules, and craft a custom preload strategy to pinpoint specific modules for early loading—all aimed at boosting application performance.
Photo by Marcin Simonides on Unsplash
My top tip: combine lazy loading with a tailored preload strategy. Target modules that are critical to the app or heavily used by users, and consider adding a subtle delay to the preload timing. I hope these techniques help streamline the performance of your Angular applications.
- Github Repo
- Feel free to play in StackBlitz





