Cutting Angular bundle size with lazy-loaded routes

JavaScript payload size has a direct impact on how quickly an app becomes interactive. As applications grow, shipping one monolithic bundle punishes every user, especially on mobile devices. Route-level code splitting is a practical remedy: split the app so each route loads only its own JavaScript chunk, fetched at navigation time rather than at startup.

Angular supports two granularities of splitting. Component-level splitting moves individual components into separate chunks loaded on demand. Route-level splitting groups everything a route needs—component, services, and templates—into one lazy chunk. This guide focuses on the route-level approach, using a sample app with a default HomeComponent and a separate nyan route rendered by NyanComponent. Both branches of the sample are available on GitHub for comparison.

From eager to lazy: the mechanical steps

In the eager configuration, the route declaration binds the URL directly to a component. To make the route lazy, two things change. First, instead of an eager component binding, the route declares loadChildren. Second, that property is fed a dynamic import that resolves to an NgModule, not a component. The routed module then declares its own default route that maps to the actual component.

The Angular CLI has automated this refactor since version 8.1.0. Running the lazy-module generator produces a new routing module, a default route inside it, the component, and rewires the parent route declaration to load the module on demand. Doing it manually makes the mechanism clear:

{
  path: 'nyan',
  loadChildren: () => import('./nyan/nyan.module').then(m => m.NyanModule)
}

The difference from the eager version is subtle but crucial: when the promise from the dynamic import resolves, Angular receives the NyanModule. The router then looks inside that module for the default route, which points to NyanComponent:

import { NgModule } from '@angular/core';
import { NyanComponent } from './nyan.component';
import { RouterModule } from '@angular/router';

@NgModule({
  declarations: [NyanComponent],
  imports: [
    RouterModule.forChild([{
      path: '',
      pathMatch: 'full',
      component: NyanComponent
    }])
  ]
})
export class NyanModule {}

Now hitting https://example.com/nyan renders NyanComponent. To confirm the chunk is truly lazy, open DevTools, switch to the Network tab, and navigate to the nyan route. The file nyan-nyan-module.js appears only after that navigation — proof the JavaScript was not part of the initial payload.

Keep users informed during the fetch

A lazy route triggers a network request, and during that brief window the viewport sits empty. Feedback matters. Angular emits routing events that make it straightforward to show a loading indicator. The RouteConfigLoadStart event fires when the lazy chunk starts downloading; RouteConfigLoadEnd fires when it finishes. An AppComponent can listen to both and flip a loading flag:

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})
export class AppComponent {
  loading: boolean;
  constructor(router: Router) {
    this.loading = false;
    router.events.subscribe(
      (event: RouterEvent): void => {
        if (event instanceof NavigationStart) {
          this.loading = true;
        } else if (event instanceof NavigationEnd) {
          this.loading = false;
        }
      }
    );
  }
}

With that state in place, the component template can conditionally render a spinner inside the router outlet element, giving the user a visual cue that something is happening.

The net effect is twofold: the initial bundle stays small, and each additional route costs nothing until the user actually visits it. The combination of automatic scaffolding from the Angular CLI and an explicit loading state gives you a faster-feeling app with minimal manual wiring.