Deferrable Views (@defer)
Deferrable views, introduced as a stable feature in Angular 17, let you lazily load a chunk of a template — and the components it depends on — only when they are actually needed. Instead of shipping every component in the initial JavaScript bundle, @defer splits the marked region into a separate chunk that the browser fetches on demand. This shrinks the initial payload, improves Core Web Vitals like LCP and TBT, and keeps your application snappy without writing a single line of manual lazy-loading plumbing.
How @defer works
@defer is a block in Angular’s built-in control flow syntax, so it works in any standalone component template without imports or directives. Any component, directive, or pipe used only inside a @defer block is automatically code-split into its own lazy chunk. When the configured trigger fires, Angular downloads that chunk, instantiates the content, and swaps it into the DOM.
@defer {
<app-comments [postId]="postId()" />
}
By default (with no trigger), the block loads on idle — when the browser becomes idle via requestIdleCallback. That single line already removes <app-comments> and its transitive dependencies from the main bundle.
The dependency must be used exclusively inside the
@deferblock. If the same component is referenced elsewhere in the template (eagerly), it stays in the main bundle and deferring it has no bundle-size benefit.
Placeholder, loading, and error blocks
A bare @defer shows nothing until its content arrives. The companion blocks let you control every phase of the lifecycle.
@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. Please retry.</p>
}
| Block | Renders when | Notable parameters |
|---|---|---|
@placeholder | Before loading starts (initial state) | minimum — minimum time to show, avoids flicker |
@loading | While the chunk is being fetched | after — delay before showing; minimum — minimum visible time |
@error | The lazy chunk fails to load | none |
The after and minimum parameters work together to prevent jarring flashes: after 100ms skips the spinner entirely for fast networks, while minimum 1s guarantees that once shown, it stays long enough to feel intentional.
Triggers
Triggers decide when the deferred content loads. They come in two flavours — on triggers respond to browser/UI events, while when evaluates an arbitrary boolean expression.
| Trigger | Loads when |
|---|---|
on idle | The browser is idle (default) |
on viewport | The placeholder (or a referenced element) scrolls into view |
on interaction | The user clicks or keys into the placeholder/element |
on hover | The pointer hovers the placeholder/element |
on immediate | As soon as the client finishes rendering |
on timer(duration) | After the given time, e.g. on timer(3s) |
when condition | The boolean expression becomes truthy |
You can combine multiple triggers; the block loads when any of them fires:
@defer (on viewport; on timer(5s)) {
<app-recommendations />
} @placeholder {
<div class="skeleton-row"></div>
}
Triggering on a referenced element
on viewport, on interaction, and on hover can watch an element outside the @defer block via a template reference variable. This is the common pattern when the placeholder itself is too small to be a meaningful viewport target.
<button #loadTrigger type="button">Show advanced settings</button>
@defer (on interaction(loadTrigger)) {
<app-advanced-settings />
} @placeholder {
<p>Advanced settings are available.</p>
}
Conditional loading with when and prefetch
The when trigger ties loading to component state — perfect for signals. You can also prefetch the chunk on one trigger while showing it on another, so the bytes are already cached when the user acts.
import { Component, signal } from '@angular/core';
@Component({
selector: 'app-dashboard',
standalone: true,
imports: [/* eager components only */],
templateUrl: './dashboard.component.html',
})
export class DashboardComponent {
showDetails = signal(false);
reveal(): void {
this.showDetails.set(true);
}
}
<button type="button" (click)="reveal()">View report</button>
@defer (when showDetails(); prefetch on idle) {
<app-detailed-report />
} @placeholder {
<p>Report hidden.</p>
} @loading (minimum 300ms) {
<app-spinner />
}
Here the chunk is prefetched quietly during idle time, but the content only renders once showDetails() flips to true.
Verifying the split
Build the app and inspect the output: the deferred component lands in its own lazy chunk rather than the main bundle.
ng build
Output:
Initial chunk files | Names | Raw size
main-A1B2C3D4.js | main | 142.07 kB
...
Lazy chunk files | Names | Raw size
chunk-9F8E7D6C.js | detailed-report | 18.44 kB
chunk-5A4B3C2D.js | heavy-chart | 31.10 kB
Best practices
- Always provide a
@placeholderso the layout does not shift when content loads; pair it withminimumto prevent flicker on fast connections. - Use
prefetchfor content the user is very likely to need (e.g. below-the-fold sections) so it feels instant when the real trigger fires. - Prefer
on viewportandon interactionfor genuinely optional content, and reserveon immediate/on idlefor content that should always load but need not block the initial render. - Ensure deferred components are not imported eagerly elsewhere, or you lose the bundle-splitting benefit entirely.
- Add an
@errorblock for any chunk whose load can realistically fail, and give users a way to retry. - Test deferred views under throttled networks in DevTools to validate your
after/minimumtimings feel natural.