Skip to content
Angular ng performance 5 min read

Lazy Loading & @defer

The fastest JavaScript is the JavaScript you never download. A user landing on your home page rarely needs the code for the admin dashboard, the analytics charts, or the rich-text editor buried three routes deep. Angular gives you two complementary tools to defer that cost: lazy-loaded routes, which split code at navigation boundaries, and the @defer block, which splits code at the template level and fetches it only when a trigger fires. Used together they shrink the initial bundle, improve Core Web Vitals like LCP and TBT, and keep your app interactive sooner.

Lazy-loaded routes

A lazy route is a route whose component (and everything it imports) lives in a separate chunk that the browser downloads only when the user navigates to it. With standalone components this is a one-liner using loadComponent, which returns a dynamic import():

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

export const routes: Routes = [
  {
    path: '',
    loadComponent: () => import('./home/home.component').then(m => m.HomeComponent),
  },
  {
    path: 'reports',
    loadComponent: () => import('./reports/reports.component').then(m => m.ReportsComponent),
  },
  {
    path: 'admin',
    loadChildren: () => import('./admin/admin.routes').then(m => m.ADMIN_ROUTES),
  },
];

Use loadComponent for a single standalone component and loadChildren to lazy-load an entire group of routes (a feature area). The imported file is only fetched the first time that path activates; after that it is cached by the browser and the router.

You can confirm the split happened by inspecting the production build output — each lazy route becomes its own chunk:

Output:

Initial chunk files | Names    | Raw size
main-7QJ4F2K.js     | main     | 138.42 kB
polyfills-RT5X.js   | polyfills|  34.58 kB

Lazy chunk files    | Names    | Raw size
chunk-AB12CD.js     | reports  |  62.10 kB
chunk-EF34GH.js     | admin    |  48.73 kB

Prefetching lazy routes

Lazy loading trades a smaller initial bundle for a small delay on first navigation. You can hide that delay by warming up chunks ahead of time with a PreloadingStrategy. PreloadAllModules fetches every lazy bundle in the background once the app is idle:

import { ApplicationConfig } from '@angular/core';
import { provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
import { routes } from './app.routes';

export const appConfig: ApplicationConfig = {
  providers: [
    provideRouter(routes, withPreloading(PreloadAllModules)),
  ],
};

PreloadAllModules is great for small-to-medium apps. For large apps, implement a custom PreloadingStrategy (or use a community quicklink strategy) that only preloads routes the user is likely to visit next — otherwise you re-download everything and lose the benefit.

The @defer block

Lazy routes split at navigation boundaries, but sometimes the heavy thing is a single widget inside an otherwise-light page — a chart, a comment thread, a map. @defer is a built-in control-flow block that code-splits that region of the template and loads it only when a trigger fires. Any component, directive, or pipe used exclusively inside the block is automatically moved into its own lazy chunk:

@defer (on viewport) {
  <app-heavy-chart [data]="metrics()" />
} @placeholder (minimum 500ms) {
  <div class="skeleton">Chart preview</div>
} @loading (after 100ms; minimum 1s) {
  <app-spinner />
} @error {
  <p class="error">Could not load the chart.</p>
}

The companion blocks control each phase: @placeholder shows before loading starts, @loading shows while the chunk downloads, and @error shows if the fetch fails. The minimum and after parameters prevent flicker on fast connections.

Defer triggers

The trigger decides when the chunk is fetched. Picking the right one matters: defer too aggressively and the content janks in late; defer too little and you keep the bundle bloated.

TriggerFires whenBest for
on idle (default)Browser is idle (requestIdleCallback)Below-the-fold, low-priority content
on viewportElement scrolls into viewCharts, images, sections further down
on interactionUser clicks/keys the placeholderEditors, modals opened by the user
on hoverPointer hovers the placeholderTooltips, preview cards
on timer(2s)After a delayNon-critical secondary widgets
when condition()A signal/expression turns truthyContent gated on app state

You can combine triggers and add prefetch to fetch the chunk early while still rendering late — for example, prefetch on idle but only render on viewport:

@defer (on viewport; prefetch on idle) {
  <app-comments [postId]="postId()" />
} @placeholder {
  <div class="skeleton">Comments</div>
}

A dependency only leaves the main bundle if it is used exclusively inside @defer. If the same component is also referenced eagerly elsewhere in the template, it stays in the initial chunk and deferring it saves nothing.

Lazy routes vs @defer

Both reduce initial load, but they operate at different granularities — use both.

Lazy routes@defer
Split boundaryWhole route / featureA region of one template
Triggered byNavigationViewport, interaction, idle, timer, condition
Lifecycle UIRoute resolvers / spinners@placeholder / @loading / @error
GranularityCoarseFine

Best Practices

  • Make every feature route lazy with loadComponent/loadChildren; keep only the shell and the initial route eager.
  • Reach for @defer for heavy, non-critical widgets inside an otherwise-light page (charts, maps, comment threads, editors).
  • Match the trigger to user intent: on viewport for below-the-fold content, on interaction for user-initiated UI like modals and editors.
  • Add prefetch on idle to defer rendering without paying a fetch delay when the trigger fires.
  • Always provide a @placeholder with stable dimensions to avoid layout shift, and use minimum/after to prevent loading-state flicker.
  • Preload lazy routes with a PreloadingStrategy, but use a targeted custom strategy rather than PreloadAllModules on large apps.
  • Verify your splits with ng build and read the lazy chunk report — if a chunk you expected is missing, a dependency is still referenced eagerly.
Last updated June 14, 2026
Was this helpful?