@defer is the cheapest performance win Angular ever shipped
One template block, and the heavy half of your page leaves the main bundle. Triggers, placeholders, prefetching, the SSR story — and the two mistakes that cancel the win.
For years, shrinking an Angular bundle meant route-level lazy loading and, below that, ceremony: dynamic import(), ViewContainerRef, loading flags, error handling you wrote yourself. So most teams did the routes and stopped there — and shipped a chart library, a markdown editor, and a date picker to every visitor who never scrolled past the hero.
@defer collapses all of that ceremony into a template block:
@defer (on viewport) {
<app-sales-chart [data]="sales()" />
} @placeholder {
<div class="chart-skeleton"></div>
}The compiler does the splitting. Everything used only inside the block — the component, its imports, the chart library it drags along — moves to a separate chunk, fetched when the trigger fires. No dynamic import, no container refs, no loading boolean. It is genuinely the highest ratio of performance gained to code written I've seen in this framework.
Choosing the trigger is the actual skill
The block is trivial; the judgment is in the trigger. My defaults:
on viewport— anything below the fold. Charts, comment sections, footers with widgets. This is the workhorse.on interaction— things that don't exist until the user asks: modals, rich-text editors, advanced filter panels. The user's click pays for the download, and a well-placed@placeholdermakes the wait invisible.on idle— the polite default for everything else: loads when the browser has nothing better to do.when condition()— logic-driven: a debug panel behind a feature flag, an admin block behind a role check. Note it's one-way — once loaded, flipping the condition back doesn't unload anything.
And the one that turns a good trigger into a great one: prefetch. Trigger and prefetch are independent, so you can fetch early and render late:
@defer (on interaction; prefetch on idle) {
<app-markdown-editor />
} @placeholder {
<button>Write a comment…</button>
}The chunk downloads while the page is quiet; the click renders it from cache. The user gets instant, you get the bundle savings. This combo — render on interaction, prefetch on idle — covers a remarkable share of real UI.
The companion blocks earn their keep
@defer (on viewport) {
<app-data-grid [rows]="rows()" />
} @placeholder (minimum 500ms) {
<app-grid-skeleton />
} @loading (after 100ms; minimum 400ms) {
<app-spinner />
} @error {
<p>Couldn't load this section. <button (click)="reload()">Retry</button></p>
}Two details people miss. The minimum on @loading isn't decoration — on fast connections a spinner that flashes for 30ms reads as a glitch; forcing a minimum display time makes it read as intent. And @error is not optional in the real world: a deferred chunk is a network request, and network requests fail on trains. A defer block without an error state is a section of your page that can silently never arrive.
The SSR story (and where it gets interesting)
On the server, a defer block renders its @placeholder — deferred chunks don't execute server-side. So your placeholder isn't just a loading state, it's what crawlers and the first paint actually see: give it real dimensions and, where it matters, real content. Since incremental hydration landed, hydrate triggers take this further: the server renders the full content, and hydration itself is what gets deferred — @defer (hydrate on viewport) gives you SEO-visible markup that only costs JavaScript when scrolled into view. For content-heavy pages, that's the best of both worlds, and it's the pattern I'd reach for first on any marketing or documentation page.
The two mistakes that cancel the win
Mistake one: deferring the initial viewport. Wrapping your hero image or above-the-fold content in @defer (on viewport) makes LCP worse — you've added a network round-trip before your most important pixels. And if the placeholder doesn't reserve the same height as the content, every load shifts the layout and your CLS score eats it. Defer is for what the user hasn't reached, never for what they're looking at.
Mistake two: referencing the deferred component anywhere eager. The compiler can only split a component out if defer blocks are its only consumers. Import it eagerly in one other place — a conditional branch elsewhere, an old NgModule declaration, a barrel file you forgot about — and it silently rides back into the main bundle. No warning, no error; the defer block still "works", it just stops saving anything. Trust the source-map explorer, not your assumptions: after adding defer blocks, look at the chunks. If the library you meant to split is still in main.js, go hunt the eager reference.
That's the whole pro tip, honestly: @defer on everything below the fold, prefetch on idle where interaction renders it, real placeholders with real heights, an @error block always — and verify the split actually happened. An hour of work on most apps, and it's the kind of hour users feel.