Fix: Angular Lazy Loading Not Working — Routes Not Code-Split
Quick Answer
How to fix Angular lazy loading not working — loadChildren syntax, standalone components, route configuration mistakes, preloading strategies, and debugging bundle splits.
The Problem
Angular lazy loading is configured but the feature module loads with the main bundle:
// app-routing.module.ts
const routes: Routes = [
{
path: 'admin',
loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule),
},
];
// Admin module still appears in main.js — lazy loading didn't workOr the lazy-loaded route causes a blank page or console error:
ERROR Error: Uncaught (in promise): Error: Cannot find module './admin/admin.module'Or in Angular 17+ with standalone components:
{
path: 'settings',
loadComponent: () => import('./settings/settings.component'),
// TypeError: Cannot read properties of undefined (reading 'ɵcmp')
}Or the route navigates but shows nothing — no component renders:
URL: /admin/dashboard
Component: (blank page)
No errors in consoleWhy This Happens
Angular lazy loading uses dynamic import() under the hood, which webpack (or esbuild) uses to create a separate chunk. Common failure causes:
- Wrong import path — if the path in
loadChildrendoesn’t match the actual file location, the dynamic import fails at runtime. - Module imported eagerly elsewhere — if
AdminModuleis imported inAppModule(or any eagerly loaded module), it ends up in the main bundle regardless ofloadChildren. Lazy loading only works when the module isn’t imported statically anywhere. - Missing router outlet — the parent route’s component must have a
<router-outlet>to render the lazy-loaded child components. - Routing module not imported in the lazy module — the lazy module needs
RouterModule.forChild(routes)to register its own routes. - Standalone component missing
defaultexport —loadComponentexpects the component to be the default export (or.then(c => c.SettingsComponent)). forRoot()in lazy module — callingRouterModule.forRoot()or otherforRoot()providers inside a lazy module creates a new injector and breaks routing.
Fix 1: Correct the loadChildren Syntax
The loadChildren callback must use dynamic import() and return the module class:
// Angular 14+ — use dynamic import (required)
const routes: Routes = [
{
path: 'admin',
// CORRECT — dynamic import with module reference
loadChildren: () =>
import('./admin/admin.module').then(m => m.AdminModule),
},
];// WRONG — string-based syntax (Angular 7 and earlier — don't use in modern Angular)
{
path: 'admin',
loadChildren: './admin/admin.module#AdminModule', // Deprecated — causes build errors
}
// WRONG — static import (defeats the purpose)
import { AdminModule } from './admin/admin.module'; // Static import at top of file
{
path: 'admin',
loadChildren: () => Promise.resolve(AdminModule), // Not actually lazy
}The import path is relative to the routing file, not the project root:
src/app/
├── app-routing.module.ts ← Routing file is here
├── admin/
│ └── admin.module.ts ← Module is at ./admin/admin.module
└── features/
└── billing/
└── billing.module.ts ← Module is at ./features/billing/billing.moduleFix 2: Remove Eager Imports of the Lazy Module
The most common reason lazy loading doesn’t work — the module is imported statically somewhere:
// app.module.ts — WRONG, causes AdminModule to be eagerly loaded
import { AdminModule } from './admin/admin.module'; // Static import
@NgModule({
imports: [
BrowserModule,
AppRoutingModule,
AdminModule, // ← This makes the module ALWAYS load with app, not lazily
],
})
export class AppModule {}// app.module.ts — CORRECT, no static import of the lazy module
@NgModule({
imports: [
BrowserModule,
AppRoutingModule, // AdminModule is referenced only via loadChildren here
// NO AdminModule import
],
})
export class AppModule {}Verify with the Angular build — check if the module creates its own chunk:
ng build --stats-json
# Then analyze with:
npx webpack-bundle-analyzer dist/your-app/stats.json
# Or check the build output for chunk files
ng build
# Look for output like:
# chunk {admin-admin-module} admin-admin-module.js (admin) → separate file = lazy loaded ✓
# If admin code is in main.js → still eagerly loaded ✗Fix 3: Set Up the Lazy Module Correctly
A lazy-loaded module needs its own routing with RouterModule.forChild():
// admin/admin-routing.module.ts
import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { AdminDashboardComponent } from './dashboard/admin-dashboard.component';
import { AdminUsersComponent } from './users/admin-users.component';
const routes: Routes = [
{
path: '', // Empty path — matches the parent route '/admin'
component: AdminDashboardComponent,
},
{
path: 'users', // Matches '/admin/users'
component: AdminUsersComponent,
},
];
@NgModule({
imports: [RouterModule.forChild(routes)], // forChild — NOT forRoot
exports: [RouterModule],
})
export class AdminRoutingModule {}// admin/admin.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { AdminRoutingModule } from './admin-routing.module';
import { AdminDashboardComponent } from './dashboard/admin-dashboard.component';
import { AdminUsersComponent } from './users/admin-users.component';
@NgModule({
declarations: [AdminDashboardComponent, AdminUsersComponent],
imports: [
CommonModule,
AdminRoutingModule, // Import the routing module
],
// DON'T import providers with forRoot() here
})
export class AdminModule {}Common Mistake: Using
RouterModule.forRoot()inside a lazy-loaded module. This creates a second root router instance and breaks navigation. Always useforRoot()only inAppModule, andforChild()in all other modules.
Fix 4: Use loadComponent for Standalone Components (Angular 14+)
Angular 14+ supports lazy-loading standalone components directly without a module:
// Standalone component — must have standalone: true
// settings/settings.component.ts
@Component({
selector: 'app-settings',
standalone: true, // Required for loadComponent
imports: [CommonModule, FormsModule],
template: `<h1>Settings</h1>`,
})
export class SettingsComponent {}// app-routing.module.ts — use loadComponent for standalone
const routes: Routes = [
{
path: 'settings',
// Default export — no .then() needed
loadComponent: () => import('./settings/settings.component'),
// ↑ Works if SettingsComponent is the default export
// Named export — use .then() to reference the class
// loadComponent: () =>
// import('./settings/settings.component').then(c => c.SettingsComponent),
},
];Make it the default export for cleaner syntax:
// settings.component.ts
@Component({ standalone: true, template: `...` })
export class SettingsComponent {}
export default SettingsComponent; // ← Default exportLazy-load routes with child routes (Angular 14+):
const routes: Routes = [
{
path: 'admin',
// Lazy-load a routes array directly (no module needed)
loadChildren: () =>
import('./admin/admin.routes').then(r => r.ADMIN_ROUTES),
},
];// admin/admin.routes.ts
import { Routes } from '@angular/router';
export const ADMIN_ROUTES: Routes = [
{
path: '',
loadComponent: () =>
import('./dashboard/admin-dashboard.component'),
},
{
path: 'users',
loadComponent: () =>
import('./users/admin-users.component'),
},
];Fix 5: Ensure Router Outlet Exists in Parent Component
Every lazy-loaded route renders inside a <router-outlet> in the parent component. If the outlet is missing, the component has nowhere to render:
// app.component.ts — root router outlet
@Component({
selector: 'app-root',
template: `
<nav>
<a routerLink="/admin">Admin</a>
<a routerLink="/settings">Settings</a>
</nav>
<router-outlet></router-outlet> <!-- Required for lazy routes to render -->
`,
})
export class AppComponent {}Named outlets for parallel route areas:
// Parent with named outlet
template: `
<router-outlet></router-outlet> <!-- Primary outlet -->
<router-outlet name="sidebar"></router-outlet> <!-- Named outlet -->
`
// Route targeting a named outlet
{
path: 'help',
component: HelpComponent,
outlet: 'sidebar', // Renders in the named outlet
}Fix 6: Configure Preloading Strategy
By default, lazy modules only load when the user navigates to their route. Preloading loads them in the background after the initial page load, improving navigation speed while keeping the initial bundle small:
// app-routing.module.ts — enable preloading
import { RouterModule, PreloadAllModules, Route } from '@angular/router';
@NgModule({
imports: [
RouterModule.forRoot(routes, {
preloadingStrategy: PreloadAllModules, // Preload all lazy modules
}),
],
exports: [RouterModule],
})
export class AppRoutingModule {}Custom preloading — only preload specific routes:
import { Injectable } from '@angular/core';
import { PreloadingStrategy, Route } from '@angular/router';
import { Observable, of } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class SelectivePreloadStrategy implements PreloadingStrategy {
preload(route: Route, load: () => Observable<unknown>): Observable<unknown> {
// Only preload routes with data.preload = true
return route.data?.['preload'] ? load() : of(null);
}
}
// In routes — mark which ones to preload
const routes: Routes = [
{
path: 'admin',
loadChildren: () => import('./admin/admin.module').then(m => m.AdminModule),
data: { preload: true }, // Preload this one
},
{
path: 'reports',
loadChildren: () => import('./reports/reports.module').then(m => m.ReportsModule),
// No data.preload — not preloaded
},
];
// Register the strategy
RouterModule.forRoot(routes, {
preloadingStrategy: SelectivePreloadStrategy,
})Fix 7: Debug with Angular DevTools
Angular DevTools (Chrome extension) shows the component tree and which modules are loaded. Use it to verify lazy loading is working:
- Install Angular DevTools from the Chrome Web Store.
- Open DevTools → Angular tab.
- Navigate to a lazy-loaded route.
- Check Profiler → Timeline to see when the module loaded.
- Check Components tree to verify the lazy component is mounted.
Network tab verification:
Before navigating to /admin:
(no admin chunk in network tab)
After navigating to /admin:
admin-admin-module.js (or similar) — loaded on demand ✓If admin-admin-module.js never appears in the Network tab, the module is not being lazy-loaded (it’s in the main bundle).
Check bundle sizes with ng build --source-map:
ng build --stats-json --source-map
npx source-map-explorer dist/your-app/*.js
# Shows which code is in which bundleStill Not Working?
Check for circular imports — if AdminModule (even indirectly) imports something that imports AppModule, the build may fail to properly chunk the module.
Verify Angular version compatibility — loadComponent requires Angular 14+. loadChildren with dynamic import() requires Angular 8+. String-based lazy loading ('./module#Class') was removed in Angular 13.
Ivy compilation issues — older pre-Ivy libraries may not be compatible with lazy loading in strict mode. Check the library’s Angular compatibility.
Route guard blocking without redirect — if a canActivate guard returns false without a redirect URL, the route changes but the component doesn’t render. Add logging to your guards:
@Injectable({ providedIn: 'root' })
export class AuthGuard implements CanActivate {
canActivate(route: ActivatedRouteSnapshot, state: RouterStateSnapshot) {
const canAccess = this.authService.isAuthenticated();
console.log('AuthGuard:', canAccess, 'for route:', state.url);
if (!canAccess) {
this.router.navigate(['/login'], { queryParams: { returnUrl: state.url } });
}
return canAccess;
}
}For related Angular issues, see Fix: Angular Change Detection Not Working and Fix: Vue Router Navigation Guard Not Working.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: RxJS Not Working — Observable Not Emitting, Memory Leak from Unsubscribed Stream, or Operator Behaving Unexpectedly
How to fix RxJS issues — subscription management, switchMap vs mergeMap vs concatMap, error handling with catchError, Subject types, cold vs hot observables, and Angular async pipe.
Fix: Angular RxJS Memory Leak — Subscriptions Not Unsubscribed
How to fix RxJS memory leaks in Angular — unsubscribing from Observables, takeUntilDestroyed, async pipe, subscription management patterns, and detecting leaks with Chrome DevTools.
Fix: Angular Form Validation Not Working — Validators Not Triggering
How to fix Angular form validation not working — Reactive Forms vs Template-Driven, custom validators, async validators, touched/dirty state, and error message display.
Fix: Angular Change Detection Not Working — View Not Updating
How to fix Angular change detection issues — OnPush strategy not triggering, async pipe, markForCheck vs detectChanges, zone.js and zoneless patterns, and manual change detection triggers.