Lazy loading a route in Angular is one line: loadChildren: () => import('./reports/reports.routes'). The bundler sees the dynamic import(), puts the reports feature in its own file, and the browser downloads it only when someone opens /reports. Smaller initial bundle, faster first load.
That one line is a request, not a guarantee. The bundler can only split the feature out if nothing that’s loaded eagerly imports any of its files statically. One stray import { Something } from './reports/...' in the app shell and the whole feature quietly moves into the initial bundle. The lazy chunk becomes a stub, nothing errors, and the app is slower than before.
This note shows that happening in a real build with the Angular 20.3 CLI (esbuild-based application builder; Angular 19 uses the same builder by default), then covers how to structure feature boundaries so it doesn’t.
A lazy reports feature
The feature has a routes file, a component, a store and a chunk of sample data large enough to see in the build output:
// reports/reports.routes.ts
export const REPORT_NAV_LABEL = 'Reports';
export const REPORTS_ROUTES: Routes = [
{ path: '', providers: [ReportStore], component: ReportList },
];
// app.config.ts
provideRouter([
{
path: 'reports',
loadChildren: () => import('./reports/reports.routes').then((m) => m.REPORTS_ROUTES),
},
]);The app shell has only a link to /reports and a <router-outlet />. ng build produced:
Initial chunk files | Names | Raw size | Estimated transfer size
chunk-UNIZFJDY.js | - | 110.28 kB | 33.13 kB
main-UT637ZOJ.js | main | 88.00 kB | 22.23 kB
polyfills-NT4JGAG7.js | polyfills | 34.59 kB | 11.33 kB
Lazy chunk files | Names | Raw size | Estimated transfer size
chunk-O3DGTWGZ.js | reports-routes | 174.41 kB | 7.57 kBAbout 233 kB raw is loaded up front, and the 174 kB reports chunk waits until it’s needed. That’s the shape you want.
One import that undoes it
Now someone wants the nav link text to come from the feature, so it stays in sync:
// app.ts
import { REPORT_NAV_LABEL } from './reports/reports.routes';
export class App {
label = REPORT_NAV_LABEL;
}It’s a string constant. It feels harmless. The next build:
Initial chunk files | Names | Raw size | Estimated transfer size
chunk-K2PKO5KA.js | - | 284.59 kB | 38.75 kB
main-W5V2HHYH.js | main | 88.06 kB | 22.29 kB
polyfills-NT4JGAG7.js | polyfills | 34.59 kB | 11.33 kB
| Initial total | 407.23 kB | 72.37 kB
Lazy chunk files | Names | Raw size | Estimated transfer size
chunk-AFIIDZ5V.js | reports-routes | 100 bytes | 100 bytesThe initial total went from about 233 kB to 407 kB, and the lazy chunk shrank to 100 bytes. The loadChildren line is still there and still “works”. It just loads a file that re-exports code the browser already has.
Why did a constant drag in everything? A static import makes reports.routes.ts part of the eager module graph. That file statically imports ReportList, which imports ReportStore and the sample data. Tree-shaking can drop unused exports, but Angular component classes carry decorator metadata and registration code that the bundler treats as side effects, so the modules they live in are kept. The safest mental model: if eager code imports a file, assume everything that file imports becomes eager too.
For comparison, importing just ReportStore (a small file with no further imports) into the app shell moved only that class: the initial total rose to 235 kB and the reports chunk stayed at 174 kB. The damage depends on what the imported file drags with it, which is exactly why routes files, which import every page of a feature, are the worst thing to import from.
Drawing the boundary
A feature folder behind a lazy route should have one entry point the rest of the app knows about, and that entry point should be referenced only through import(). Practical rules:
- The app shell imports nothing from feature folders. Not components, not constants, not types that live in the same file as components. Type-only imports (
import type { Report }) are erased at compile time and are safe, but they’re easy to turn into value imports by accident. - Shared things live in shared places. Nav labels, route paths and interfaces needed by both the shell and the feature belong in a small
shared/orcore/module that doesn’t import feature code. - Features don’t import each other’s internals. If
ordersimports a component fromreports, the reports code ends up in the orders chunk (or a shared chunk both load). Move the shared component out of both features. - Check the build output. The “Lazy chunk files” table is the test. If a chunk you expect to be big is tiny, or missing, something eager is importing it. Teams often enforce this with lint rules (Nx’s
enforce-module-boundaries, or ESLintno-restricted-importsper folder) and with bundle budgets inangular.jsonso a jump in initial size fails CI.
ng build --stats-json writes stats.json, which you can load into an esbuild bundle analyser to see which file pulled which module into which chunk. When the initial bundle grows unexpectedly, that’s the quickest way to find the culprit import.
Route providers as a feature scope
The reports route declares providers: [ReportStore]. That creates an environment injector for the feature. Every component under that route shares one ReportStore, and nothing outside the route can inject it. That’s a clean replacement for what lazy NgModules used to give you.
Two behaviours surprise people. First, the environment injector is created when the route is first activated and, by default, isn’t destroyed when the user leaves. A router test that stamped ids on instances got the same instance back after navigating away and returning. That’s handy for keeping filters, and wrong if you expected fresh state per visit; for that, provide the service on the routed component instead. Second, a service with providedIn: 'root' that a lazy feature imports is still a root singleton, but its code moves into the lazy chunk if only that feature uses it. providedIn: 'root' controls the instance scope, not the chunk. The note on hierarchical providers covers the injector side in more detail.
Preloading and when lazy isn’t better
Lazy loading trades first-load time for a delay when the user first opens the feature. Preloading recovers most of that delay: provideRouter(routes, withPreloading(PreloadAllModules)) downloads lazy chunks in the background after the app boots. For large apps, a custom PreloadingStrategy that preloads only routes marked data: { preload: true } is common.
Splitting also has a floor. A feature that’s 5 kB isn’t worth its own request, and routes almost every user visits immediately (the dashboard after login) gain little. Lazy-load features that are large, rarely visited, or permission-gated. For parts within a page, @defer blocks give the same benefit at template level; see the note on deferred loading.
Interview practice
After a refactor, a team notices their initial bundle grew by 300 kB, yet every feature still uses loadChildren, and navigation works. The lazy chunks in the build output are all under 1 kB. What would you suspect, and how would you find and fix it?
Answer
Tiny lazy chunks with a bigger initial bundle mean the features’ code is being imported statically from eager code, so the bundler already has it in the initial graph and the lazy chunk only re-exports it. The loadChildren still runs, which is why navigation works and nothing errors.
To find it, I’d search the shell (app.ts, app.config.ts, layout components, core services) for imports from feature folders, for example grep -rn "from './features/" src/app --include=*.ts outside the features themselves. Typical culprits are a constant or enum exported from a routes file, a feature component used in a shared header, or a barrel index.ts at the features root that re-exports everything. For a definitive answer I’d build with --stats-json and use a bundle analyser to see which import put each module in the initial chunk.
The fix is to move whatever the shell genuinely needs (labels, paths, interfaces) into a shared module that doesn’t import feature code, delete any barrel that spans features, and make sure feature entry points are only reached through import(). To stop it recurring, I’d add an initial bundle budget in angular.json that errors on a jump, and a lint rule restricting imports into feature folders.