Skip to content
back to blog

Your dashboard route is already lazy loaded. A visitor who never opens the dashboard never downloads it. Then you open it on a throttled connection and the route still costs 480 kB before it can render a single panel, because a charting library sits in the middle of the route template and nothing around it can paint until the library arrives.

That is the moment an @defer block becomes interesting. A route boundary is one line across the application, drawn at the level of the URL, and the heavy things in a real application rarely sit on that line. The chart editor, the code editor, the map, the rich text editor: each one is a large dependency that a small share of visits actually uses, and each one currently blocks everything around it.

@defer moves the boundary. Instead of asking the router to decide what a URL needs, you tell the template which region of the page is optional and when it should appear. The browser skips that code until your trigger fires, and the rest of the page renders without waiting.

By the end you will be able to defer a genuinely heavy component with no route change, choose the trigger deliberately, measure the real delta in the initial bundle and the network waterfall, and recognise the ways this fails quietly.

What a defer block compiles to

A defer block is not a rendering feature. It is a code splitting instruction with a scheduler attached.

When the compiler meets @defer, it inspects the components, directives, and pipes used inside the block and emits a dynamic import() for each one that qualifies. Those imports, plus everything reachable only through them, become a chunk of their own. Everything outside the block stays in the chunk it was already in.

Two conditions decide whether a dependency qualifies, and both are easy to miss. It must be standalone, and it must not be referenced anywhere else in the same file. If the component also appears outside the block, or a @ViewChild query names it, Angular keeps it in the eager bundle and the block has nothing left to split. Dependencies declared in an NgModule are not deferred either, although their own transitive dependencies can be.

At runtime the deferrable dependency becomes a dynamic import(), and Angular attaches a scheduler to decide when to fire it. You never write that wiring by hand. You declare the trigger, the compiler emits the wiring, and the only thing you observe is the extra chunk in the network panel.

The scheduling is pluggable. The default idle scheduler sits on requestIdleCallback, and in Angular 22 you can swap in your own scheduler with provideIdleServiceWith if your application already has one.

The block renders once every import resolves, and Angular does not promise an order between them. Three deferred components means three requests, and the block waits for the slowest.

How this differs from route-level lazy loading

Both mechanisms end up as a dynamic import() in the output, so the difference is not in the bundler. It is in who decides, and when.

  • Route lazy loading is a router concern. The unit is loadChildren or loadComponent, the trigger is a navigation event, and there is one boundary per URL.
  • A defer block is a template concern. The unit is a region of a template, the trigger is whatever you configure, and one page can carry as many blocks as it needs.
  • Route lazy loading cannot help a component that shares a route with three lightweight panels. That is the common case, and it is the case @defer exists for.

They also compose. Keep the route lazy loaded and defer inside it. The route chunk is still the price of admission. The defer block is how you avoid paying for the parts of the page nobody asked for.

Three loaded summary cards across the top and one dimmed, still empty chart panel below

The trigger is a decision, not a default

A bare @defer with no trigger is on idle. That is a sensible default, and it is also a decision you did not make.

The full set:

  • on idle, and on idle(500) with a timeout: schedules through requestIdleCallback. The timeout is a backstop so the callback cannot be starved by a busy page. Angular 22 lets you replace the scheduler with provideIdleServiceWith if the application already has one.
  • on viewport: watches an element with IntersectionObserver and fires when it enters the viewport.
  • on interaction: fires on click or keydown on the watched element.
  • on hover: fires on mouseover or focusin on the watched element.
  • on timer(2s): fires after a duration, written in ms or s.
  • on immediate: fires as soon as the non-deferred content has rendered. It is a code split without a delay, rather than a real deferral.
  • when condition: fires the first time the expression is truthy, and never returns to the placeholder if it becomes falsy again.

Triggers are separated with a semicolon and evaluated as OR. Two triggers mean whichever fires first wins, not both.

Three details decide whether a trigger works at all. First, on viewport, on interaction, and on hover watch the placeholder by default, and a placeholder used that way must have a single root element. A placeholder with two sibling elements produces a trigger that never fires, and Angular has no way to know you meant the outer one. Second, you can point those triggers at a template reference variable instead, which is usually clearer:

<div #chartSlot class="chart-slot">Revenue</div>

@defer (on interaction(chartSlot)) {
  <heavy-chart [data]="data()" />
} @placeholder {
  <div class="chart-slot">Open the chart</div>
}

Third, the object form of the viewport trigger accepts real IntersectionObserver options, which is how you stop a component from loading the instant one pixel crosses the fold:

<div #chartSlot class="chart-slot"></div>

@defer (
  on viewport({ trigger: chartSlot, rootMargin: '200px', threshold: 0.25 })
) {
  <heavy-chart [data]="data()" />
}

Picking one on purpose

Work through these in order and stop at the first one that matches.

  1. Is the block inside the first viewport on a typical screen? Do not defer it, or reach for incremental hydration instead. A placeholder swap above the fold buys a bundle win with layout shift, and that is the one trade where the page feels worse.
  2. Must the user do something before the block can be useful? Use on interaction, or when on the signal that flips when they do.
  3. Is the block below the fold? Use on viewport, possibly with a rootMargin that starts the fetch slightly before the element is visible.
  4. Is the block expensive, secondary, and the page genuinely busy for a moment after load? on idle is honest work, and it is the only trigger that yields to the browser's own scheduling.
  5. Do you want the code out of the initial bundle while still rendering in the first frames, for example during a migration? on immediate does exactly that and nothing more.

A timer is the one to reach for last. on timer(3s) encodes a guess about user behaviour, and that guess is usually wrong.

An empty placeholder widget lighting up green as the mouse cursor approaches it

Prefetch is a second decision

Everything above decides when the block renders. A prefetch trigger decides when its bytes arrive. Conflating the two loses most of the value.

@defer (on interaction; prefetch on idle) says: start downloading when the browser is quiet, but do not render until the user clicks. On a warm cache the click is instant, because the chunk is already in memory. On a cold cache the click pays for a network round trip before anything appears, which is precisely the latency you were trying to hide.

The prefetch side takes the same triggers, prefixed: prefetch on idle, prefetch on viewport, prefetch on hover, prefetch on timer(1s), prefetch when ready(), prefetch on immediate. The viewport object form works there too. The pairings that earn their keep:

  • Prefetch on idle, render on interaction. The user has to ask for the block, and the browser has spare time before they do. This is the combination I use most, because on a quiet page it costs almost nothing.
  • Prefetch on hover, render on interaction. Pointer movement usually precedes the click by a few hundred milliseconds, which on a decent connection is most of a round trip.
  • Prefetch nothing. The right answer for a block that most visitors never open. A chunk nobody needed is a saving, and a chunk prefetched for nobody is a cost with no return.

The price is bandwidth at the worst moment. A prefetch fires while the page is still painting and competes with the LCP image, the fonts, and the main bundle for connection slots. On a desktop connection that is invisible. On a throttled phone it can push out the thing the user sees first. Prefetch is a bet that the user will need the block, and prefetch on idle is the least aggressive version of that bet, because the browser starts only when it has nothing better to do.

One consequence to know before you debug it: when the prefetch wins the race, the @loading block may never appear, because the chunk has already resolved by the time the render trigger fires. That is the system working as designed, and it is the reason the loading block should be a rare state rather than the expected one.

Placeholder, loading, and error

Three optional blocks cover the gap between the trigger firing and the content existing. @placeholder renders before the trigger fires. @loading renders while the chunk downloads. @error renders when the import rejects, which in practice means a dropped connection or a chunk hash that no longer exists after a deploy.

None of the three is free. Their own dependencies are eagerly loaded, so a placeholder built from three heavy components to save one is self defeating. Keep them to markup and CSS.

Both @placeholder and @loading accept a minimum, and @loading also accepts an after:

@defer (on viewport) {
  <heavy-chart [data]="data()" />
} @placeholder (minimum 500ms) {
  <div class="chart-slot"><p>Revenue chart</p></div>
} @loading (after 150ms; minimum 400ms) {
  <div class="chart-slot"><div class="skeleton-bar"></div></div>
} @error {
  <div class="chart-slot">
    <p>The revenue chart did not load.</p>
    <button type="button" (click)="reload()">Reload the page</button>
  </div>
}

after 150ms keeps the loading block hidden for the first 150 ms of the download. minimum 400ms keeps it on screen for at least 400 ms once it appears, even if the chunk lands immediately afterwards. Both timers start when loading is triggered, so after measures the fetch rather than the page.

That pair of numbers exists because of how people read a flicker. A skeleton that appears for 40 ms does not read as progress. It reads as a glitch: something flashed, and the user trusts the page a little less afterwards. The same skeleton on screen for 600 ms reads as a page that is working. The total time is identical, and the second version feels faster. So when fast loads are common, delay the loading state rather than showing it early and often.

minimum on the placeholder solves the same problem from the other direction. Without it, a warm cache swap can flash the placeholder for a single frame. The placeholder takes minimum only, since after belongs to the loading block.

One layout detail matters as much as the timing: reserve the space. Give the placeholder, the loading block, and the loaded component the same wrapper, min-height, or aspect ratio, so the page does not reflow when the real content arrives. Otherwise you traded bundle weight for cumulative layout shift, which is a poor deal in front of a Core Web Vitals budget.

One widget in three states: empty placeholder, loading skeleton, then a loaded line chart

Measuring the win

You do not need a lab. You need two numbers and one waterfall.

Build before the change, build after, and compare the Initial total row in the build table. The deferred dependencies should appear as a new lazy chunk, and the initial total should drop by roughly the size of that chunk.

# capture the baseline first
ng build --configuration production

# after adding the @defer block
ng build --configuration production --stats-json

The --stats-json flag writes an esbuild metafile to stats.json next to prerendered-routes.json in the build output directory, and you can load that file into the analyzer at esbuild.github.io/analyze to see which source files moved. That answers what the build table cannot: not just how much left, but what left. If the chart library, its date library, and its locale files all show up under the new lazy chunk, the split worked. If they are still in the entry chunk, the compiler decided one of them did not qualify, and no amount of trigger tuning will change that.

A useful shape of result: Initial total falls from 512 kB to 148 kB, and a 364 kB lazy chunk appears with the charting library inside it. Your numbers will differ. The shape should not.

Then watch the network. Open the Network panel, filter to JS, disable the cache, throttle to something honest, reload, and answer three questions:

  1. Is the deferred chunk requested before the trigger fires? If it is, the block is not deferring anything.
  2. After the trigger fires, how long is the gap before the block appears? That gap is what the user feels, and it is where a prefetch pays for itself.
  3. What priority did that request get? A lazy chunk is normally low priority, which is what you want, but a prefetch that fires while the LCP image is still downloading can still take a connection slot.

For a repeatable check you can record the same thing from inside the page:

function watchChunk(chunkName: string) {
  const observer = new PerformanceObserver(list => {
    for (const entry of list.getEntries()) {
      if (!entry.name.includes(chunkName)) continue;
      const started = Math.round(entry.startTime);
      const finished = Math.round(entry.responseEnd);
      console.log(`${chunkName} started at ${started}ms, finished at ${finished}ms`);
    }
  });
  observer.observe({ type: 'resource', buffered: true });
}

// Chunk file names come from the build table, or from the Resource Timing
// entries of a run where you already know the block loaded.
watchChunk('heavy-chart');

Careful with the entry name, because the hashed file name is what the browser sees, not the source path. Read performance.getEntriesByType('resource') unfiltered and look for the JavaScript files that the initial HTML never mentions. Compare startTime against the moment you triggered the block, and you have measured the deferral instead of assuming it.

One number is easy to forget, and it decides whether the change was worth it: the cost on the other side. If the block renders on interaction, the click that opens it now waits for a network round trip that did not exist before. Initial load went down and first interaction went up. For a chart that appears on demand that is a reasonable trade. For the primary action of the page it is a bad one, and no bundle number will tell you which case you are in. Measure both.

Where it bites

Server rendering and hydration

On a server render, defer blocks always render their placeholder, and triggers are not invoked on the server. The client hydrates the placeholder and only then arms the triggers. So an above-the-fold block costs you a placeholder first paint and a layout shift, and if that block contained the largest contentful paint element, you moved work off the bundle critical path and onto the render critical path.

The fix is incremental hydration, which Angular enables by default with provideClientHydration(). Adding a hydrate trigger tells Angular to load the block dependencies during server rendering and render the real template, while the client still keeps the block dehydrated until the hydrate trigger fires:

@defer (on viewport; hydrate on interaction) {
  <heavy-chart [data]="data()" />
} @placeholder {
  <div class="chart-slot"></div>
}

Read that as two different rules for two different loads. hydrate on interaction applies to the initial server rendered load, and the events a user fires before hydration, as long as they match listeners in the component, are queued and replayed, so an early click is not lost. on viewport still applies on every later client side navigation. hydrate never is the option for content that needs no client behaviour at all on that first load, and on a later client side render the regular trigger takes over anyway.

Components that must be interactive immediately

Do not defer a control that has to accept input the moment it is visible, and do not defer a wrapper that contains one, because a defer boundary takes the whole subtree with it. A search box, a date field, the primary call to action, anything a keyboard user tabs to early: all of it belongs outside the block.

The failure is quiet. The markup is not there yet, so the first keystroke goes nowhere and the user concludes the page is broken. On a client rendered page there is a second effect to design around: the click that fires an on interaction trigger is consumed by the trigger and is not forwarded to the content that appears afterwards. If the placeholder looks like a button, users will click twice, once to load and once to use. Make the placeholder look like a load action, or prefetch so the second click is instant.

Dependencies that arrive anyway

This is where a working setup stops working without saying anything. A dependency is only split if it is not referenced elsewhere in the same file, and the barrel file is the usual culprit:

// index.ts
export { HeavyChartComponent } from './heavy-chart.component';
export { SummaryCardComponent } from './summary-card.component';

// dashboard.component.ts
import { HeavyChartComponent, SummaryCardComponent } from './index';

The bundler sees the barrel as one module and keeps its exports together, so importing the summary card drags the chart into the initial bundle along with it. Nothing fails. The build succeeds, the bundle grows, and the only signal is a number in a table that nobody reads until the numbers are bad. Import the deferred component from its own file and the split appears:

import { HeavyChartComponent } from './heavy-chart.component';
import { SummaryCardComponent } from './summary-card.component';

There is a second false negative that can waste an afternoon. With HMR active, Angular fetches every defer chunk eagerly and overrides the triggers, so a dev server makes @defer look broken. Verify chunk timing against a production build, or serve with --no-hmr, before you look for a bug in your template.

The same analysis covers the case where the chunk appears and the initial bundle does not shrink: the defer block gets its chunk, and a second eager import keeps a copy in the entry bundle. Search for every import of the component before you tune the trigger.

Putting it together

A dashboard with an eager summary and a deferred chart, wired the way this post describes:

<section class="dashboard">
  <app-summary [totals]="totals()" />

  @defer (on viewport; prefetch on idle) {
    <app-revenue-chart [series]="series()" (exported)="onExport($event)" />
  } @placeholder (minimum 400ms) {
    <div class="chart-slot" aria-hidden="true"></div>
  } @loading (after 150ms; minimum 400ms) {
    <div class="chart-slot"><div class="skeleton-bar"></div></div>
  } @error {
    <div class="chart-slot">
      <p>The revenue chart did not load.</p>
      <button type="button" (click)="reload()">Reload the page</button>
    </div>
  }
</section>

The component behind it stays ordinary, with one detail in the error path:

import { Component, inject, signal } from '@angular/core';
import { SummaryComponent } from './summary.component';
import { RevenueChartComponent } from './revenue-chart.component';
import { AnalyticsService } from './analytics.service';

@Component({
  selector: 'app-dashboard',
  imports: [SummaryComponent, RevenueChartComponent],
  templateUrl: './dashboard.component.html',
})
export class DashboardComponent {
  private readonly analytics = inject(AnalyticsService);
  readonly totals = signal<Totals | null>(null);
  readonly series = signal<Series | null>(null);

  constructor() {
    this.analytics.load().then(({ totals, series }) => {
      this.totals.set(totals);
      this.series.set(series);
    });
  }

  onExport(payload: ExportPayload) {
    this.analytics.recordExport(payload);
  }

  reload() {
    location.reload();
  }
}

The error path is deliberately a full reload. The browser remembers a failed module for that URL inside the document, so calling import() again does not retry the network. A page load is what fetches a valid chunk list and a working chunk hash, which is exactly what a deploy leaves behind in a cached tab.

Before you ship, wrap the block in a live region so a screen reader announces the swap when the chart finally arrives.

What to take away

@defer moves a code splitting boundary from the URL into the template, which is where the heavy component that shares a route with everything else actually lives. Write the trigger on purpose, treat prefetch as a separate decision, size the placeholder so the swap does not move the page, and prove the result with the initial total and the network waterfall rather than with the presence of the syntax in your template.

Then check the two ways it lies: a barrel file that keeps the dependency eager, and a dev server fetching every chunk up front. Both look like success from the template.