Angular Intermediate #5: Standalone and Lazy Loading
When the app is small, downloading all the code at once is no problem. But once you have dozens of screens and start pulling in heavy dependencies — chart libraries, editors, admin-only screens — the story changes. Forcing users to download a chart library that’s only used on the admin page, just to render the first screen they see, is wasteful.
At the end of Basics #6 Router we briefly showed lazy-loading a single route with loadComponent. In this post we break that topic open. The starting point, surprisingly, is the imports array of standalone components. The model where “a component knows its own dependencies” is the single biggest reason lazy loading is as clean as it is today.
Standalone Components in depth #
We saw the shape of a standalone component back in Basics #2. Let’s look again.
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { AvatarComponent } from '../shared/avatar.component';
@Component({
selector: 'app-user-card',
standalone: true,
imports: [CommonModule, RouterLink, AvatarComponent],
templateUrl: './user-card.component.html',
})
export class UserCardComponent {
// ...
}The key is the imports array. The other components, directives, and pipes the template uses are declared by the component itself. There’s no NgModule middleman, so the compiler — and the build tools — can see the dependency graph clearly.
Why does this matter for lazy loading? When the compiler knows exactly what each component pulls in, it can automatically decide how to split chunks at build time and which code goes where. That’s why a single () => import(...) line in a route is enough to produce a clean chunk split.
One more practical point — a standalone component is itself a unit a route can target. You can pass it directly to loadComponent without wrapping it in a dedicated module. Compared to the old NgModule days, when lazy-loading a single page meant creating a dedicated module and a dedicated routing module, that’s a huge simplification.
A quick look back at NgModule #
Before Angular 14, every component, directive, and pipe had to be registered to a unit called NgModule. Folders were dotted with *.module.ts files — AppModule, SharedModule, UserModule — each densely packed with declarations, imports, exports, and providers.
@NgModule({
declarations: [UserListComponent, UserDetailComponent],
imports: [CommonModule, RouterModule.forChild(routes)],
providers: [UserService],
})
export class UserModule {}The problems were excessive boilerplate, the need to remember exports whenever you wanted to use a component elsewhere, and a dependency flow that spread across modules and was hard to trace.
When standalone landed in Angular 14 and became the default in v17, NgModule was effectively pushed into legacy territory. New projects all use standalone, and the official docs, tutorials, and ng new output are all standalone-first.
@NgModule, declarations, or RouterModule.forRoot(...), recognize them as “ah, the legacy pattern.” The current recommendation: standalone for new code; keep existing code as-is and migrate to standalone gradually.Eager loading vs Lazy loading #
When you put a route’s component directly in the route definition, that component is loaded eagerly.
import { HomeComponent } from './pages/home.component';
import { AdminComponent } from './pages/admin.component';
export const routes: Routes = [
{ path: '', component: HomeComponent },
{ path: 'admin', component: AdminComponent },
];The moment you import AdminComponent, that code is linked to the route definition file, the route definition is linked to app.config.ts, and ultimately it’s pulled into the initial bundle. Even if the user never visits /admin, they still download the admin code on first load.
Lazy loading is the technique of moving that import inside a function.
export const routes: Routes = [
{ path: '', component: HomeComponent },
{
path: 'admin',
loadComponent: () => import('./pages/admin.component').then(m => m.AdminComponent),
},
];The build tooling (esbuild/webpack) sees the import('./pages/admin.component') pattern and splits it into its own chunk. That chunk only flies over the network and runs once the user navigates to /admin.
The decision criteria are simple.
- Frequently visited and lightweight → eager wins. Splitting it would add a small delay on each navigation
- Rarely visited and heavy (admin pages, analytics dashboards, settings screens) → lazy is the answer
- Pre-login and post-login screens are entirely different → splitting login/signup as eager and the main app as lazy is a common pattern
Per-route lazy with loadComponent
#
The lightest split unit is a single route. The pattern above is the standard.
export const routes: Routes = [
{ path: '', component: HomeComponent },
{
path: 'reports',
loadComponent: () =>
import('./pages/reports/reports.page').then(m => m.ReportsPage),
},
{
path: 'settings',
loadComponent: () =>
import('./pages/settings/settings.page').then(m => m.SettingsPage),
},
];.then(m => m.ReportsPage) looks like boilerplate, and you can shorten it. Export the component as a default export.
@Component({
selector: 'app-reports',
standalone: true,
template: `<h1>Reports</h1>`,
})
export default class ReportsPage {}{
path: 'reports',
loadComponent: () => import('./pages/reports/reports.page'),
},The whole .then step disappears. That said, a file can have only one default export, so this fits page components where the file exports just one thing. Making a shared component a default export means you have to invent a new name everywhere you import it, which is more of an annoyance than a benefit.
Per-route-group lazy with loadChildren
#
When several pages belong to the same area rather than one isolated page, loadChildren is cleaner. For instance, if /admin houses dashboard, user management, and settings screens, you can split that whole group lazily.
import { Routes } from '@angular/router';
export const ADMIN_ROUTES: Routes = [
{
path: '',
loadComponent: () =>
import('./pages/admin-shell.component').then(m => m.AdminShellComponent),
children: [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{
path: 'dashboard',
loadComponent: () =>
import('./pages/dashboard.page').then(m => m.DashboardPage),
},
{
path: 'users',
loadComponent: () =>
import('./pages/users.page').then(m => m.UsersPage),
},
],
},
];export const routes: Routes = [
{ path: '', component: HomeComponent },
{
path: 'admin',
loadChildren: () =>
import('./admin/admin.routes').then(m => m.ADMIN_ROUTES),
},
];The first time the user enters /admin, admin.routes.ts and every component imported within it are downloaded as a chunk. Treating a route group as a single unit lines up nicely with feature-folder structure.
Provider also scoped to the lazy section #
If you have a service used only inside the lazy section, put its provider in the lazy route’s providers. The service code is bundled into the lazy chunk, and no instance is created until the user enters that area.
export const ADMIN_ROUTES: Routes = [
{
path: '',
providers: [
provideHttpClient(),
AdminAnalyticsService,
{ provide: REPORT_API_BASE, useValue: '/api/admin' },
],
loadComponent: () =>
import('./pages/admin-shell.component').then(m => m.AdminShellComponent),
children: [/* ... */],
},
];A route-level providers creates an isolated injector scoped to that subtree. When the same token exists at the root and inside the lazy section, the lazy section uses the lazy instance. Good for cleanly isolating things like an HTTP client or analytics service whose configuration differs per area.
Build analysis #
To check whether your lazy chunks are splitting correctly, look at the production build output.
ng buildWhen the build finishes, the terminal prints chunk sizes.
Initial chunk files | Names | Raw size
main.abc123.js | main | 142.5 kB
polyfills.def456.js | polyfills | 33.2 kB
Lazy chunk files | Names | Raw size
chunk-ghi789.js | admin-routes | 78.4 kB
chunk-jkl012.js | reports-page | 24.1 kBWhat’s in Initial is the code downloaded for the first page load. If a heavy library (chart, editor, PDF viewer, etc.) shows up here that shouldn’t be there, it’s a candidate for moving to lazy.
To see exactly what’s inside each chunk, source-map-explorer is handy.
npm install -g source-map-explorer
ng build --source-map
source-map-explorer dist/my-app/browser/*.jsA treemap opens in the browser and shows which library takes how much space in which chunk. Decisive when debugging things like “why is chart.js in the main chunk?”
Preloading strategies #
The downside of lazy is the small delay on first navigation — there’s an extra wait for the chunk to download. Preloading softens that — you fetch chunks in the background before the user clicks the menu.
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
import { routes } from './app.routes';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes, withPreloading(PreloadAllModules)),
],
};The name PreloadAllModules is a relic of the NgModule era, but the constant works fine in the standalone era. Its meaning is “right after the initial load, fetch all lazy chunks in the background.”
If preloading everything is too much, you can build a custom strategy that fetches only when the user is idle, or only specific routes.
import { Injectable } from '@angular/core';
import { PreloadingStrategy, Route } from '@angular/router';
import { Observable, of, timer, switchMap } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class PreloadOnIdleStrategy implements PreloadingStrategy {
preload(_route: Route, load: () => Observable<unknown>): Observable<unknown> {
return timer(2000).pipe(switchMap(() => load()));
}
}provideRouter(routes, withPreloading(PreloadOnIdleStrategy)),Two seconds after the first render, lazy chunks quietly start downloading in the background. By the time the user clicks the menu, the chunks are already cached, so the perceived delay is essentially gone. It’s a cost-effective pattern that captures both “the bundle-size benefits of lazy” and “the immediacy of eager.”
Common mistakes #
A few traps when first applying lazy loading.
1. Importing a large component from the eager side inside a lazy section
// admin/pages/dashboard.page.ts (lazy chunk)
import { HomeChartComponent } from '../../home/home-chart.component';Even if HomeChartComponent is a heavy component already in the main chunk, importing it from the lazy side makes the bundler pull it into the lazy chunk too. The same code ends up duplicated across two chunks. If you really need to share it, move it into a shared folder and have both sides depend on the same chunk.
2. A “kitchen sink” shared module/array
Some folks try to recreate the NgModule-era SharedModule in the standalone era. The idea is “gather common components, directives, and pipes into one array and spread it everywhere via imports,” but it’s the prime suspect for breaking lazy chunk splitting. Even when you only use one component, the whole array drags in all its dependencies. In modern Angular, the standard is to list imports per component, explicitly.
3. Lumping every service under providedIn: 'root'
It’s easy to make @Injectable({ providedIn: 'root' }) reflexive when defining a service. That’s right for genuinely global services, but for a service used only inside a lazy area, putting it in the lazy route’s providers is better for chunk splitting.
4. Libraries that aren’t tree-shakable
This isn’t your fault but the library’s, but some libraries pull the entire package in from a single import. Check with source-map-explorer; if the size looks unreasonable, look up the library’s “modular import” guide. For example, you might have to import per-function: import { format } from 'date-fns'.
Wrapping up #
In this post we made one pass through modern Angular’s code-splitting strategy. To summarize:
- Standalone’s explicit
importsare the solid foundation for code splitting - NgModule is legacy. Recognize it when you see it; for new code, go standalone
- The line between eager and lazy is “is it worth putting in the initial bundle?”
loadComponentfor one route,loadChildrenfor a route group,providersto isolate per area- Build analysis to regularly check what code lives in which chunk
- Preloading to effectively erase the lazy delay
In the next post, “Angular Intermediate #6 Guards and Resolvers,” we cover the other side of Router — Guards that check authorization before entering a route, and Resolvers that pre-fetch data before the screen is shown. Indispensable tools for production routing with auth attached.