Skip to content

Fix: Angular Lazy Loading Not Working — Routes Not Code-Split

FixDevs · (Updated: )

Part of:  React & Frontend Errors

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 work

Or 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 console

Why This Happens

Angular lazy loading uses dynamic import() under the hood, which webpack (or esbuild) uses to create a separate chunk. The bundler sees the dynamic import and splits that module into its own file, loaded on demand when the route activates.

The mechanism is fragile because it depends on the module being referenced only through the dynamic import() call. If any other file in the application has a static import { AdminModule } from './admin/admin.module' statement — even in a barrel file, a shared module, or a testing helper — the bundler pulls AdminModule into the main chunk at build time. The loadChildren still points to it, but the split never happens because the bundler already included it. This is the single most common reason lazy loading silently fails, and it produces no errors or warnings.

The second major pitfall involves standalone components in Angular 14+. With loadComponent, the imported file must export the component either as a default export or through a .then() accessor. If the component is a named export and you forget the .then(c => c.SettingsComponent), Angular receives the module object instead of the component class and throws TypeError: Cannot read properties of undefined (reading 'ɵcmp').

Other failure causes:

  • Wrong import path — if the path in loadChildren doesn’t match the actual file location, the dynamic import fails at runtime.
  • 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.
  • forRoot() in lazy module — calling RouterModule.forRoot() or other forRoot() providers inside a lazy module creates a new injector and breaks routing.

Diagnostic Timeline

When lazy loading appears to be configured correctly but the feature module still ends up in the main bundle, follow this sequence.

Minute 0 — Check the network tab, not the route. Navigate to the lazy route with DevTools open on the Network tab. Filter by JS files. If no new chunk appears when you navigate (such as admin-admin-module.js or a hashed chunk name), the module is in the main bundle. If a chunk does appear but the page is blank, the problem is routing configuration, not code splitting.

Minute 2 — Search for eager imports of the lazy module. This is the most likely cause. Run a project-wide search for the module name:

grep -rn "AdminModule" --include="*.ts" src/

Every result that is a static import statement (not inside loadChildren) defeats lazy loading. A single import { AdminModule } in app.module.ts, a shared module, or even a barrel index.ts is enough. Remove every static import of the lazy module except the one inside the dynamic import() call.

Minute 5 — Verify the build output. Run ng build and inspect the dist/ directory. Lazy-loaded modules produce their own chunk files. If you see admin-admin-module.js (or a similarly named chunk), lazy loading is working at the build level. If all admin code is inside main.js, the split did not happen.

ng build --stats-json
npx webpack-bundle-analyzer dist/your-app/stats.json

Minute 8 — Check the lazy module’s internal routing. The lazy module must use RouterModule.forChild(routes), not forRoot(). And it must declare a route for the empty path '', which corresponds to the parent’s loadChildren path. If the empty path route is missing, navigating to /admin renders nothing.

Minute 10 — Test the import path. In your IDE, Ctrl+Click the string inside import('./admin/admin.module'). If it doesn’t resolve to a file, the path is wrong. The path is relative to the file containing the loadChildren declaration.

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.module

Fix 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

Pro Tip: Barrel files (index.ts) are a hidden source of eager imports. If src/app/index.ts re-exports AdminModule and another file imports from that barrel, the bundler pulls AdminModule into the main chunk. Audit every barrel file in your project for re-exports of lazy modules.

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 use forRoot() only in AppModule, and forChild() 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 export

Lazy-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:

  1. Install Angular DevTools from the Chrome Web Store.
  2. Open DevTools → Angular tab.
  3. Navigate to a lazy-loaded route.
  4. Check ProfilerTimeline to see when the module loaded.
  5. 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 bundle

Still 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 compatibilityloadComponent 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;
  }
}

Shared module importing the lazy module’s components — if a shared module (imported by AppModule) declares or imports components from the lazy module, those components get pulled into the main bundle. Lazy modules should be completely self-contained. Move shared components into a separate shared module that both AppModule and the lazy module import independently.

esbuild (Angular 17+) chunk naming — Angular 17 defaults to the esbuild-based builder, which names chunks differently from webpack. Instead of admin-admin-module.js, you might see hashed filenames like chunk-ABCDEF.js. Check dist/browser/ for multiple JS files to confirm code splitting is active.

provideRouter in standalone apps — if you migrated to a fully standalone app with bootstrapApplication, make sure you pass routes through provideRouter(routes, withPreloading(...)) in the bootstrap call. Forgetting to register routes there means no lazy loading occurs.

For related Angular issues, see Fix: Angular Change Detection Not Working, Fix: Vue Router Navigation Guard Not Working, Fix: Angular NullInjectorError, and Fix: Angular SSR Not Working.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles