Implementing Lazy Loading in Angular for Faster Initial Loads
A large Angular application can feel sluggish before a user has done anything. The browser must download JavaScript, parse it, compile or execute application code, and then render the first useful screen. If every feature is bundled into the initial payload, a visitor pays for routes they may never open.
Lazy loading changes that trade-off by splitting an application into smaller chunks and downloading each feature when it becomes necessary. A customer visiting a public home page does not need the administration dashboard, reporting tools, or account settings in the first request.
This matters in Australia, where users may access an application over busy NBN connections, mobile networks, or slower links in regional areas. Even when an application is hosted near Sydney or Melbourne, a user in Perth, Darwin, or a rural community can notice unnecessary downloads. A smaller first load makes the experience more consistent across the country.
Why Lazy Loading Improves Angular Performance
Angular’s build tools create JavaScript bundles from the application source. Without route-level splitting, commonly used and rarely used features can end up in the same initial bundle. The browser downloads that bundle before it can display the route, so the first screen becomes tied to the size of the entire application.
Lazy loading divides the application into an initial bundle and additional chunks. The initial bundle contains the shell and the code required for the first route. When a user navigates to another feature, Angular requests the corresponding chunk from the server. This reduces initial transfer size and usually improves metrics such as Largest Contentful Paint and Time to Interactive.
The benefit is especially clear for applications with separate areas such as bookings, invoices, reports, and staff management. A small brochure site may gain little from complex route splitting, while a customer portal can see a substantial difference. Measure the initial JavaScript size before making changes, then compare it with the generated chunks afterwards.
Lazy loading does not make all code disappear. A feature still has to be downloaded eventually, and the first visit to that route may include a short wait. The goal is to prioritise the screen the user needs now and defer code that belongs to later journeys.
Lazy Loading Routes With Standalone Components
Modern Angular applications commonly use standalone components and route configuration. A route can load a component only when its URL is activated:
import { Routes } from '@angular/router';
export const routes: Routes = [
{
path: '',
loadComponent: () =>
import('./pages/home/home.component')
.then(m => m.HomeComponent)
},
{
path: 'reports',
loadComponent: () =>
import('./pages/reports/reports.component')
.then(m => m.ReportsComponent)
}
];
The dynamic import() tells the build system to place ReportsComponent in a separate chunk. A visitor opening / does not download the reports component until navigating to /reports. The component and its imported dependencies are loaded together as part of that feature’s chunk.
For a group of related routes, use loadChildren with a separate route file:
export const routes: Routes = [
{
path: 'account',
loadChildren: () =>
import('./account/account.routes')
.then(m => m.ACCOUNT_ROUTES)
}
];
The feature route file can then contain its own child routes:
import { Routes } from '@angular/router';
export const ACCOUNT_ROUTES: Routes = [
{
path: '',
loadComponent: () =>
import('./account-home.component')
.then(m => m.AccountHomeComponent)
},
{
path: 'settings',
loadComponent: () =>
import('./settings.component')
.then(m => m.SettingsComponent)
}
];
This structure creates a useful boundary around the account feature. It also keeps route definitions close to the components they control, which can make a growing codebase easier to maintain.
Lazy Loading Existing Angular Modules
Applications using NgModules can still use route-level lazy loading. The route points to a module rather than a standalone component:
const routes: Routes = [
{
path: 'admin',
loadChildren: () =>
import('./admin/admin.module')
.then(m => m.AdminModule)
}
];
The feature module must define its own child route configuration. Its main route is usually an empty path:
const routes: Routes = [
{
path: '',
component: AdminComponent,
children: [
{
path: 'users',
component: UsersComponent
}
]
}
];
Avoid importing the lazy module into the root AppModule. Doing so can pull its code back into the initial bundle and defeat route splitting. The root application should reference the module through loadChildren only.
The same rule applies to large shared dependencies. A lazy feature can still become unexpectedly large if it imports a broad utility library, a complete charting package, or many shared declarations. Keep shared modules focused, and import only the Angular Material components or third-party features that the route actually uses.
A staged migration is practical for an older codebase. Begin with one self-contained area, inspect the build output, and then move other feature modules gradually. This is often easier than converting an entire enterprise application to standalone APIs in one release.
Choosing Preloading And Navigation Strategies
Loading a feature only after a click is not always the best experience. Angular supports preloading strategies that fetch lazy chunks in the background after the initial route has rendered. The user sees a fast first screen, while likely next routes become ready in advance.
The simplest built-in option is PreloadAllModules:
import { PreloadAllModules, provideRouter, withPreloading } from '@angular/router';
bootstrapApplication(AppComponent, {
providers: [
provideRouter(routes, withPreloading(PreloadAllModules))
]
});
For an NgModule application, configure the router with the equivalent preloading option. This approach is suitable when the application has a reliable audience and the deferred features are reasonably sized. It may be wasteful for users on limited mobile data because every lazy chunk will eventually download.
A custom strategy can preload selected routes:
import { Injectable } from '@angular/core';
import {
PreloadingStrategy,
Route
} from '@angular/router';
import { Observable, of } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class SelectivePreloadingStrategy
implements PreloadingStrategy {
preload(route: Route, load: () => Observable<unknown>) {
return route.data?.['preload'] ? load() : of(null);
}
}
Mark only suitable routes for background loading:
{
path: 'products',
loadChildren: () =>
import('./products/products.routes')
.then(m => m.PRODUCT_ROUTES),
data: { preload: true }
}
Consider user behaviour and geography when selecting a strategy. An Australian retail site might preload a product catalogue after its home page, but defer a rarely used wholesale portal. A service used across AEST, ACST, and AWST time zones may have different traffic patterns by region, so real analytics are more useful than assumptions.
Protecting Data And Handling Lazy Chunk Failures
Lazy loading is a performance technique, not an access-control mechanism. A route can be hidden from the navigation menu and still be requested directly. Use route guards for user experience and authorization checks, but enforce permissions on the server for every protected API operation.
A route can combine lazy loading with a guard:
{
path: 'admin',
canMatch: [adminGuard],
loadChildren: () =>
import('./admin/admin.routes')
.then(m => m.ADMIN_ROUTES)
}
canMatch can prevent Angular from matching or loading the feature for an ineligible user. The API must still verify the user’s role, because a browser user can inspect requests and invoke endpoints without following the application’s navigation.
Deployments can create a separate problem. If a user has an old application shell open while a new release removes or renames a chunk, navigation can fail with a ChunkLoadError. Configure your hosting and cache headers so the application shell and generated assets are versioned sensibly. Keeping old hashed assets available briefly can help users complete a session during a rollout.
You can also provide a recovery path for failed imports. A global error handler may detect a chunk failure, log it, and reload once so the browser obtains the current shell. Avoid infinite reload loops, and show a useful message if the network is unavailable. This matters for users on trains, regional roads, or patchy mobile coverage, where a failed request may be temporary rather than a deployment fault.
Measuring The Results In A Real Application
Build production bundles before judging the effect of lazy loading. Development builds include extra code and are not representative of what users download. Angular’s build output identifies initial and lazy chunks, while tools such as Lighthouse, Chrome DevTools, and WebPageTest show transfer sizes, request timing, and rendering milestones.
Use a realistic test matrix. Compare a fast desktop connection with a throttled mobile profile, and test routes from more than one hosting region. An application hosted in an Australian cloud region may perform well in Sydney but still expose oversized chunks to users in Perth or remote areas. Network distance is only part of the result; bundle size, server response time, and device performance also matter.
Watch for accidental eager imports. A component referenced directly by the root component, a provider that imports feature code, or a barrel file that re-exports too much can cause a supposedly lazy dependency to move into the initial bundle. Inspect the generated statistics when a chunk is unexpectedly large.
Testing should cover both navigation and direct URLs. Open a lazy route in a fresh browser session, refresh it, navigate to it from another page, and confirm that deep links work on the production server. End-to-end tests should verify loading states, authorization redirects, and sensible behaviour when a chunk request fails.
A Practical Rollout Checklist
Treat route splitting as part of application design rather than a last-minute build switch. Group code around user journeys, keep the shell small, and make a deliberate decision about which routes deserve preloading. For many teams, one well-chosen feature provides enough evidence to establish a repeatable approach.
- Identify the largest initial bundles and the least frequently used application areas.
- Split self-contained routes with
loadComponentorloadChildren. - Remove direct root imports that accidentally make lazy features eager.
- Choose immediate loading, selective preloading, or no preloading based on user behaviour and data costs.
- Test protected routes, deep links, refreshes, offline conditions, and deployment transitions.
- Compare production performance with Australian mobile, regional, and metropolitan network scenarios.
The quality of the result depends on more than a smaller number in the build report. A route that displays a blank screen while a large chunk downloads may still feel slow, so provide a clear loading state and avoid requesting feature data before the component is ready. Use skeleton content where it improves perceived performance, and keep the first render useful on both desktop and mobile screens.
Lazy loading is also easier to maintain when the boundaries reflect the business domain. A reports feature should own its report components, data-access services, and route configuration rather than importing half of the application. Clear boundaries reduce the chance that a future change quietly expands the initial bundle.
Start by measuring the current production build, then lazy-load one meaningful feature and measure again. Apply the pattern to the routes with the greatest impact, check the experience from Sydney to regional Australia, and ship the improvement with confidence. Your users should receive the page they came for first, while the rest of the application arrives only when it earns its place.