DEV Community

Sanket Bhor
Sanket Bhor

Posted on

loadComponent vs loadChildren in Angular 19: Choosing the Right Lazy-Loading Boundary

There's a pattern I've been seeing more frequently across Angular codebases since standalone components became mainstream.

A team migrates away from NgModules. They discover loadComponent. It's simpler, requires less boilerplate, and feels aligned with where Angular is heading. So they start replacing loadChildren calls across the routing configuration — one by one, sometimes all at once.

The build passes. The application behaves exactly as before. Bundle reports may even suggest the migration was a success.

The problem is that routing decisions are judged by what happens immediately after deployment. Architectural decisions are judged six months later.

I've reviewed migrations where every loadChildren route was replaced with loadComponent in a single PR. The application kept working. Six months later the route structure no longer reflected the application's feature boundaries. Ownership became harder to understand. Shared concerns started leaking across routes. The routing configuration became flatter — but not simpler. One of those reviews turned into a two-sprint refactor that the team hadn't budgeted for.

Angular 19 made standalone the default. New projects no longer generate NgModules, which means loadComponent is now the first lazy-loading API many developers encounter. That makes the distinction between these two APIs more important than ever — not less.

This article is not about which API is newer. It's about understanding the architectural boundary each one creates, and why treating them as interchangeable is a mistake that compounds quietly over time.


What Angular Actually Says

The Angular routing documentation is accurate but brief on this distinction.

loadComponent lazily loads a single standalone component when its route becomes active. loadChildren lazily loads a set of child routes — typically from a separate routes file. Both use dynamic import() under the hood. Both are fully supported in modern Angular.

The documentation stops there. The architectural reasoning is left to the reader, which is where most of the confusion starts.


The Architectural Difference: Component Boundary vs Feature Boundary

The surface-level difference is obvious: one loads a component, one loads routes.

The more important difference is the scope of the lazy-loading boundary.

loadComponent creates a boundary around a single screen. When a user navigates to /help, Angular loads HelpComponent and nothing else.

loadChildren creates a boundary around a feature domain. When a user navigates to /admin, Angular loads a route configuration that may contain dozens of child routes, shared providers, feature-specific guards, and related components — all bundled together as a cohesive unit.

// loadComponent — boundary is one screen
{
  path: 'help',
  loadComponent: () => import('./help/help.component').then(m => m.HelpComponent)
}

// loadChildren — boundary is an entire feature domain
{
  path: 'admin',
  loadChildren: () => import('./admin/admin.routes').then(m => m.adminRoutes)
}
Enter fullscreen mode Exit fullscreen mode

Visualising where boundaries should sit makes this clearer:

Application
│
├── /help          → loadComponent   (isolated screen, no sub-routes)
├── /about         → loadComponent   (isolated screen, no sub-routes)
├── /terms         → loadComponent   (isolated screen, no sub-routes)
│
├── /admin         → loadChildren    (feature domain)
│   ├── /users
│   ├── /users/:id
│   ├── /roles
│   └── /audit
│
├── /dashboard     → loadChildren    (feature domain)
│   ├── /analytics
│   └── /reports
│
└── /checkout      → loadChildren    (feature domain — shared state, sequential flow)
    ├── /cart
    ├── /shipping
    └── /payment
Enter fullscreen mode Exit fullscreen mode

The question is never "which API is more modern." The question is: where should the lazy-loading boundary exist for this part of the application?

Dimension loadComponent loadChildren
Lazy-loading scope Single component Feature route set
Chunk granularity Component-level Feature-level
Route organisation Flat Hierarchical
Shared providers Possible but duplicated across routes Grouped at feature level, scoped to feature lifetime
Route-level providers Supported, but scoping breaks down at scale Naturally scoped via parent route providers array
Guard and resolver inheritance Must be repeated per route Applied once at the feature boundary
Scalability Good for isolated pages Good for feature domains
Team ownership Per-route Per-feature file

When loadComponent Works Best

Some routes are genuinely independent. They have no sub-navigation. They don't share services with adjacent routes. They don't need their own route hierarchy.

Good candidates:

  • /help — static content, visited rarely
  • /about — no dependencies on feature services
  • /terms, /privacy — purely informational
  • /error — error boundary pages
  • A lightweight settings landing page that links out to deeper sections

For these routes, loadComponent is the right answer. There's no feature boundary to model, no shared providers to group, and no sub-routes that logically belong together.

export const routes: Routes = [
  {
    path: 'help',
    loadComponent: () => import('./help/help.component').then(m => m.HelpComponent)
  },
  {
    path: 'about',
    loadComponent: () => import('./about/about.component').then(m => m.AboutComponent)
  },
  {
    path: 'terms',
    loadComponent: () => import('./legal/terms.component').then(m => m.TermsComponent)
  }
];
Enter fullscreen mode Exit fullscreen mode

No extra route file. No unnecessary abstraction. Just a lazy-loaded screen.


When loadChildren Is the Correct Architecture

Now consider an admin area. It contains user management, role configuration, audit logs, reporting, and a dashboard. These routes share services. They share guards. They have their own sub-navigation. They belong to the same business domain and are typically owned by the same team.

Here's what it looks like when loadComponent is overused on a feature domain:

// What this looks like when loadComponent is applied to a feature domain
export const routes: Routes = [
  { path: 'admin/dashboard', loadComponent: () => import('./admin/dashboard/dashboard.component').then(m => m.DashboardComponent) },
  { path: 'admin/users', loadComponent: () => import('./admin/users/users.component').then(m => m.UsersComponent) },
  { path: 'admin/users/:id', loadComponent: () => import('./admin/users/user-detail.component').then(m => m.UserDetailComponent) },
  { path: 'admin/roles', loadComponent: () => import('./admin/roles/roles.component').then(m => m.RolesComponent) },
  { path: 'admin/audit', loadComponent: () => import('./admin/audit/audit-log.component').then(m => m.AuditLogComponent) },
];
Enter fullscreen mode Exit fullscreen mode

This flat structure has real costs. Guards and resolvers must be repeated on every route individually. Providers must either be hoisted to root — where they live for the entire application lifetime — or duplicated across route definitions. Child route hierarchy is gone: /admin/users/:id is now a peer of /admin/dashboard at the application level rather than a child of /admin/users. Ownership is unclear.

Here's what it should look like:

// Feature domain correctly modelled as loadChildren
export const routes: Routes = [
  {
    path: 'admin',
    loadChildren: () => import('./admin/admin.routes').then(m => m.adminRoutes),
    providers: [
      AdminUserService,
      AdminAuditService,
    ]
  }
];
Enter fullscreen mode Exit fullscreen mode

And inside admin.routes.ts:

export const adminRoutes: Routes = [
  {
    path: '',
    canActivate: [AdminGuard],
    children: [
      { path: 'dashboard', loadComponent: () => import('./dashboard/dashboard.component').then(m => m.DashboardComponent) },
      { path: 'users', loadComponent: () => import('./users/users.component').then(m => m.UsersComponent) },
      { path: 'users/:id', loadComponent: () => import('./users/user-detail.component').then(m => m.UserDetailComponent) },
      { path: 'roles', loadComponent: () => import('./roles/roles.component').then(m => m.RolesComponent) },
      { path: 'audit', loadComponent: () => import('./audit/audit-log.component').then(m => m.AuditLogComponent) },
    ]
  }
];
Enter fullscreen mode Exit fullscreen mode

This structure does several things the flat version cannot:

  • AdminGuard is applied once at the feature boundary, not repeated on five routes
  • AdminUserService and AdminAuditService are scoped to the admin feature — instantiated when the admin section loads, destroyed when the user leaves it (assuming default router configuration without a custom RouteReuseStrategy)
  • /admin/users/:id is correctly a child of /admin/users, not an application-level peer
  • The admin team owns and modifies admin.routes.ts without touching the root routing configuration
  • Route data, breadcrumb metadata, and analytics identifiers cascade naturally to all child routes

Worth calling out explicitly: loadComponent does support a providers array. The technical limitation isn't that providers are unavailable — it's that when five sibling routes all need the same service, you either duplicate the provider declaration across all five or hoist it to root. Neither is correct. loadChildren solves this cleanly because the scoping boundary and the feature boundary are the same thing.

The architectural signal is straightforward: if you find yourself writing admin/ as a prefix on five separate loadComponent routes, you've missed a feature boundary.


A Note on Chunk Count and Preloading

It's tempting to treat more lazy chunks as automatically better. I've seen teams celebrate an increase in lazy-loaded chunks without measuring the actual impact. That's optimising an assumption, not a result.

More chunks mean more network requests. Whether that matters depends heavily on your preloading strategy. With PreloadAllModules or QuicklinkStrategy, Angular preloads lazy chunks in the background after initial load — which means chunk granularity becomes less critical for perceived performance. With no preloading, a large feature chunk loaded on first navigation to /admin is a real cost.

Before restructuring routes for bundle reasons, run ng build --stats-json and load the output into Webpack Bundle Analyzer. Measure the before and after. If you haven't measured, you haven't optimised — you've just changed things.


Common Mistakes

Replacing every loadChildren call with loadComponent during a standalone migration.
This is the most common mistake I see. Standalone components and route architecture are separate concerns. A standalone component works perfectly inside a loadChildren route file. Migrating one does not require changing the other. I've reviewed PRs where an engineer replaced every loadChildren call in a single commit and labelled it a "standalone migration" — it wasn't. It was an architectural change that happened to compile cleanly.

Losing hierarchical route structure.
When loadChildren is replaced with flat loadComponent routes, child route hierarchy disappears. Guards, resolvers, and providers that once applied at the feature level must now be repeated per route. This surfaces in code review as canActivate: [AuthGuard] duplicated across every admin route — a clear sign that a feature boundary was missed.

Treating standalone components as an architecture strategy.
Standalone components improve dependency declaration and compilation. They say nothing about how routes should be organised. The routing structure decisions still belong to the engineering team.

Optimising without measuring.
Run bundle analysis before and after any routing restructure. Smaller bundles are not automatically better. More chunks are not automatically better. Your preloading strategy changes what "better" means.


When loadChildren Is Overkill

The case for loadChildren at feature boundaries holds for medium-to-large applications. It is not universally correct.

A small internal tool. A side project. A marketing site. A single-team application with eight routes. These gain little from introducing route files and feature boundaries everywhere. The abstraction costs more than it saves.

The honest rule: use loadChildren when the feature boundary is real — when there are multiple related routes, shared providers, and a clear reason to group them together. Don't introduce it to impose large-application structure on a codebase that isn't there yet. Premature route architecture is still premature optimisation.

The inflection point is usually recognisable: when you find yourself duplicating guards across related routes, or when a new developer can't tell which routes belong together by reading the root configuration, the feature boundary has been missed and loadChildren will pay for itself quickly.


What I Use in Production

On a 600+ component Angular platform serving enterprise clients, both strategies coexist and have distinct jobs.

Feature domains — client configuration, reporting, user administration, the main product dashboard — all use loadChildren. These are coherent domains with their own providers, guards, and sub-navigation. Fragmenting them into individual loadComponent calls would make the routing configuration harder to read and the feature boundaries harder to maintain. In a monorepo, these feature route files map directly to library boundaries, which means the routing structure reflects actual team ownership.

Isolated screens — the help centre, terms pages, error boundaries, and a handful of rarely visited informational pages — use loadComponent. No sub-routes, no shared dependencies, no reason to create a route file.

Nobody on the team debates the APIs in isolation. The conversation is usually about ownership, shared dependencies, and deployment boundaries. The routing decision follows from those answers.


Decision Matrix

Scenario Recommendation Reason
Help / About / Terms pages loadComponent Isolated, no sub-routes, no shared dependencies
Error boundaries loadComponent Independent, no feature services
Admin area loadChildren Feature domain with sub-routes, shared guards, shared services
Reporting dashboard loadChildren Multiple related routes, shared data services
Multi-step checkout flow loadChildren Shared state, guards, sequential child routes
User account section loadChildren Profile, billing, security — related routes with shared context
Marketing pages loadComponent Independent, no feature services
Small internal tool (<8 routes total) loadComponent Overhead of feature files not justified
Feature-rich business domain loadChildren Architecture boundary, not just a routing convenience

Final Thoughts

Most routing mistakes aren't caused by choosing the wrong API. They're caused by placing the lazy-loading boundary in the wrong place.

loadComponent works best when a route represents a genuinely independent screen — one component, no children, no shared dependencies.

loadChildren works best when a route represents a feature domain — a coherent set of routes that share providers, guards, and ownership.

The strongest Angular routing configurations use both. Not because mixing APIs is sophisticated, but because real applications contain both isolated screens and feature domains. The routing configuration should reflect that honestly.

When evaluating a route, I've stopped asking "which API should I use?" I ask: what is the actual boundary here?

If the answer is "a page," I reach for loadComponent.

If the answer is "a feature," I reach for loadChildren.

Once the boundary is clear, the API choice is obvious.


If you're in the middle of a standalone migration and replacing loadChildren calls wholesale — slow down. Ask whether each replacement is an architectural decision or a syntax preference. The answer is not always the same, and the difference matters six months from now.


References


I write about frontend problems from real projects — follow if that's useful.

If this helped, drop a ❤️ — it helps with visibility.

Connect with me:

Top comments (0)