Updated on

Lazy loading tells the Angular router to download part of the application the first time someone navigates to it, instead of shipping all of it in the first bundle. A route swaps its component property for loadChildren or loadComponent, and the value becomes a dynamic import() the router calls on demand.

Here we build the owner feature as its own area, wire it behind a lazy route, and fetch the owner list from the API once someone navigates there. Angular HttpClient and Environment Files built the repository service this uses.

For complete navigation and all the basic instructions of the Angular series, check out the Introduction of the Angular series.

To download the source code for this article, you can visit the LazyLoading folder in our GitHub repository. The source code for the whole series is here.
The server application this client calls is the one from our ASP.NET Core Web API series. Its code is in the UsingRepositoryForWriteRequests folder in our GitHub repository, and the script that creates the AccountOwner database and fills it with the owners we are about to see is in the Database folder beside it.

What Is Lazy Loading in Angular?

Lazy loading means the browser downloads a part of an Angular application only when the user first navigates to it, instead of shipping all of it up front.

The router is where this happens. A route with a component property pulls that component into the bundle that declares the route. A route with loadComponent or loadChildren holds a dynamic import() instead, and the router runs that import the first time someone navigates there.

The payoff is the first load. An application with an admin area, a reporting area and a settings area sends none of them to a visitor who only opens the home page.

The cost is a pause on that first navigation, while the chunk downloads. The router accepts a preloading strategy that fetches lazy chunks in the background once the application has started, trading a little bandwidth for that pause.

Lazy loading is a routing decision rather than a component one, which is why it is configured in the routes and nowhere else.

What Replaces the Feature Module in a Standalone Application?

A lazily loaded feature lives in its own folder: a component to render, and a routes file that says how to reach it. There is no module to generate, so the CLI writes the component and we write the routes file ourselves.

ng g component owner/owner-list --skip-tests

That writes owner-list.ts, owner-list.html and owner-list.css under src/app/owner/owner-list, and --skip-tests leaves out the spec file, the same flag the earlier parts used.

Nothing else in the application changes, and that is the difference a standalone project makes. There is no owner.module.ts with a declarations array to add the component to, and no app.module.ts to import that module into. A standalone component names its own dependencies in its own imports array, so a feature needs no module to hold them.

What we want is for the Owner Actions menu to show this component’s content. So first, just for testing purposes, let’s put a single paragraph in the component’s template, owner-list.html:

<p>This is owner-list component page.</p>

The feature also needs a routes file, so let’s create owner.routes.ts beside the component folder:

import { Routes } from '@angular/router';

export const routes: Routes = [];

It exports a plain Routes array and nothing else: no decorator, no NgModule, nothing to register anywhere. The array is empty at this point because the file has to exist before the application’s routes can import it.

How Do We Configure a Lazy-Loaded Route?

A lazy route is a route whose destination is a dynamic import(). The router calls that import on first navigation, waits for the chunk, and then activates whatever came back.

loadChildren loads a whole feature area. Its import resolves to a set of child routes, and every path inside is mounted under the parent’s path, so a parent path of owner and a child path of list produce /owner/list.

loadComponent loads a single component, which suits a route with no children of its own.

Both replace component on that route, and they can appear together, with the component acting as the shell the child routes render inside.

One rule decides whether any of it works. Nothing loaded eagerly may import the lazy file, because a single import from an eagerly loaded module pulls the whole feature back into the initial bundle. The build succeeds, the route still works, and the laziness is silently gone.

The diagram below shows what that leaves in the first bundle, and what waits until someone navigates to the owner feature.

The Angular main bundle holding the App, Menu, Home and NotFound components and app.routes.ts, with the owner chunk downloaded separately on first navigation to /owner.

With the feature in place, let’s point the application’s routes at it, in app.routes.ts:

import { Routes } from '@angular/router';

import { Home } from './home/home';
import { NotFound } from './error-pages/not-found/not-found';

export const routes: Routes = [
  { path: 'home', component: Home },
  { path: 'owner', loadChildren: () => import('./owner/owner.routes').then(m => m.routes) },
  { path: '404', component: NotFound },
  { path: '', redirectTo: '/home', pathMatch: 'full' },
  { path: '**', redirectTo: '/404' }
];

The owner route carries loadChildren instead of component, and its value is a function that returns a dynamic import(). Angular’s route loading strategies guide states the rule in one line: “The loadChildren property lazily loads child routes during route matching.” The router calls that function the first time someone navigates into /owner, and reads the routes array off the file it gets back. The wildcard route stays last, because the router takes the first match it finds.

The import list at the top of the file is the other half of the setup. Home and NotFound are imported there, so they travel in the first bundle. owner.routes is not imported anywhere in the eagerly loaded application, and that is what keeps the feature out of that bundle.

So when we open the Home page, the browser downloads only what the root of the application needs. The owner feature’s chunk arrives the first time we click the Owner Actions menu and not before, which is the reason lazy loading matters as an application grows.

How Do Child Routes Work Under a Parent Path?

To enable navigation to the OwnerList component, the feature’s routes file needs a route of its own:

import { Routes } from '@angular/router';

import { OwnerList } from './owner-list/owner-list';

export const routes: Routes = [
  { path: 'list', component: OwnerList }
];

With this setup, we expose our OwnerList component on the http://localhost:4200/owner/list endpoint. The list path is relative to the owner path that loaded the file, so the two join into one URL, and this array registers itself nowhere. provideRouter(routes) in app.config.ts registers the top-level array once, and the router mounts anything it loads later under the path that loaded it.

An older NgModule project wrapped the same array in RouterModule.forChild(routes) inside a second module file. A standalone routes file needs neither.

Now we have to modify the menu.html file:

<div class="collapse navbar-collapse" id="collapseNav" [collapse]="!isExpanded" [isAnimated]="true">
  <ul class="navbar-nav me-auto mb-2 mb-lg-0">
    <li class="nav-item">
      <a class="nav-link" [routerLink]="['/owner/list']" routerLinkActive="active"
      [routerLinkActiveOptions]="{exact: true}"> Owner Actions </a>
    </li>
    <li class="nav-item">
      <a class="nav-link" href="#">Account Actions </a>
    </li>
  </ul>
</div>

After all of these modifications, we can run our app and click the Owner Actions link. As soon as we do that, our new component shows up, and the link gets an active class style:

Angular Lazy Loading of the Owner Component

Now we know how to set up the routing for a feature area, and for the component inside that feature as well.

Subscription and Data Display

When we navigate to the Owner Actions menu, we want to show all of the owners to the user. So, when the owner list component loads, the application asks the server for them.

We already have our Owner interface created in the previous part, and we use it here.

That said, let’s modify the owner-list.ts file:

import { DatePipe } from '@angular/common';
import { Component, OnInit, inject, signal } from '@angular/core';

import { Owner } from '../../_interfaces/owner.model';
import { OwnerRepositoryService } from '../../shared/services/owner-repository.service';

@Component({
  imports: [DatePipe],
  selector: 'app-owner-list',
  styleUrl: './owner-list.css',
  templateUrl: './owner-list.html',
})
export class OwnerList implements OnInit {
  owners = signal<Owner[]>([]);

  private repository = inject(OwnerRepositoryService);

  ngOnInit(): void {
    this.getAllOwners();
  }

  private getAllOwners = () => {
    const apiAddress: string = 'api/owner';
    this.repository.getOwners(apiAddress)
    .subscribe(own => {
      this.owners.set(own);
    })
  }

}

The owners field is a signal, which is a wrapper around a value that notifies Angular when that value changes. We create it with signal<Owner[]>([]), an empty array until the request comes back, and this.owners.set(own) inside the subscription is the one line that fills it.

Reading a signal means calling it, in the class and in the template alike, so the template below writes owners() rather than owners. An Angular 22 application runs without zone.js and its components are OnPush, so nothing polls this component for changes: Angular re-renders it because its template read a signal and that signal changed.

To accomplish that, let’s replace that test paragraph with the real table, in owner-list.html:

<div class="row">
  <div class="offset-10 col-md-2 mt-2"> <a href="#">Create owner</a> </div>
</div> <br>
<div class="row">
  <div class="col-md-12">
    <div class="table-responsive">
      <table class="table table-striped">
        <thead>
          <tr>
            <th>Owner name</th>
            <th>Owner address</th>
            <th>Date of birth</th>
            <th>Details</th>
            <th>Update</th>
            <th>Delete</th>
          </tr>
        </thead>
        <tbody>
          @for (owner of owners(); track owner.id) {
            <tr>
              <td>{{owner.name}}</td>
              <td>{{owner.address}}</td>
              <td>{{owner.dateOfBirth | date: 'dd/MM/yyyy'}}</td>
              <td><button type="button" id="details" class="btn btn-primary">Details</button></td>
              <td><button type="button" id="update" class="btn btn-success">Update</button></td>
              <td><button type="button" id="delete" class="btn btn-danger">Delete</button></td>
            </tr>
          }
        </tbody>
      </table>
    </div>
  </div>
</div>

We use some basic Bootstrap classes to create a table showing the owner’s data. Inside that table, @for loops over owners(), and track owner.id tells Angular which row is which when the collection changes. Then, by using interpolation {{}}, we show owner properties on the page. For the dateOfBirth property we use the date pipe, | date: 'dd/MM/yyyy', to format it the way we want to see it on a screen, and the component imports DatePipe for it, because a standalone component brings in whatever its own template uses.

We could split this table into a parent component and a child component. It stays as one here, because the owner list is all this part needs.

In our application we are going to use the date format MM/dd/yyyy, but here we use dd/MM/yyyy just to demonstrate the way to change the format with pipes without too much effort.

Now we can start our server application, the API whose write actions we added in part 6, where the POST, PUT and DELETE actions are built. Once it runs, we can start our Angular app and navigate to the Owner Actions link:

Data displayed on the page

When Do We Use loadComponent or @defer?

loadChildren is the right tool for a feature area with several routes. A route that has no children of its own can point loadComponent at a component file directly, and the router loads that one component on first navigation.

The Angular documentation shows both on one route: loadComponent for the area’s shell and loadChildren for the routes that render inside it. Neither form needs .then(m => m.SomeModule) to pull a class off an imported module.

The saving is a file per feature. The older shape needed a feature module and a feature routing module before one component could load lazily. The newer one needs a routes file, and the components name their own dependencies.

Deferrable views cover what routing cannot. A @defer block loads its contents on a trigger such as viewport or interaction, so a heavy chart far down a page arrives when the reader scrolls to it rather than when the route activates.

Route propertyWhat it loadsWhen the code is fetchedStatus today
component: OwnerListone component, imported normallywith the bundle that declares the routefine for small routes every visitor reaches
loadComponent: () => import('./owner/owner-list')one standalone componenton first navigation to that routethe standalone default for a leaf route
loadChildren: () => import('./owner/owner.routes').then(m => m.routes)a child Routes array and everything it nameson first navigation into the child paththe standalone way to lazy load a feature area
loadChildren: () => import('./owner/owner.module').then(m => m.OwnerModule)an NgModule and everything it declareson first navigation into the child pathworks, but Angular recommends standalone components instead of NgModule for all new code
@defer { } in a templatea component and its dependencieson the block's trigger: idle, viewport, interaction, hover, immediate, timer or whentemplate-level deferral, for content that is not its own route

One error message is worth knowing before converting an older feature. Point loadComponent at an NgModule and the Angular router’s route-configuration check refuses it, in a message that names the fix: “You are using ‘loadComponent’ with a module, but it must be used with standalone components. Use ‘loadChildren’ instead.”

Conclusion

Lazy loading is a small change to one route that decides how much code a first-time visitor has to download. The owner feature now lives in its own folder behind its own routes file, and none of it reaches the browser until somebody opens it.

In this post we have learned:

  • What lazy loading is, and that it is configured on routes rather than on components
  • How to put a feature behind loadChildren so its code arrives on first navigation
  • How child routing works under a parent path, and why the wildcard route stays last
  • How to hold the fetched owners in a signal and display them with @for, formatting a date with the date pipe
  • When loadComponent suits a route better than loadChildren, and when @defer fits better than either

In Angular Error Handling: HTTP Errors and Error Pages, we handle the failures these requests can return, and build the details page for a single owner.

This series follows the conventions collected in our Angular best practices guide, which is worth a read before starting a project of your own.

Tested with Angular 22.1.5, Angular CLI 22.1.7, ngx-bootstrap 22.0.0, Bootstrap 5.3.8, TypeScript 6.0.3 and Node 24.