The dashboard's first screen was mostly calm — a header, a row of summary cards, a filter bar. Nothing on that fold was expensive. And yet the page took its time becoming interactive, and the profile told a story that didn't match what the user could see: most of the cost was a charting library and a dense visualization component that lived near the bottom, past the fold, where nobody had scrolled yet.
The page was paying, up front, for a feature most sessions never looked at. First paint waited on code that was rendering to a part of the screen no one had reached.
Lazy loading was already on, at the level I knew it. The route split its own bundle; the dashboard loaded only when you navigated to it. But the chart wasn't behind a route — it was inside this one, in the same template as the summary cards, so it rode along in the same chunk and executed on the same load. Route-level splitting is a wall between pages. My problem was inside a single page, and I needed a boundary I could draw around one component.
A heavy widget in the initial bundle is like a stagehand rolling the grand piano onto the stage before the show starts. The audience is seated, the opening act is ready, and everyone is waiting on a piano that isn't needed until the second half.
@defer draws the boundary around the component
@defer is a template block, and that placement is the point — it lets me split off a piece of a page rather than a whole route. Anything referenced only inside the block becomes its own lazy chunk, left out of the initial bundle and fetched when a trigger says it's time. The block comes with the states a real deferred load needs: a @placeholder to hold the layout, a @loading for the fetch, an @error for when it fails.
The trigger is the second decision, and for a below-the-fold widget on viewport is the honest one — don't spend anything on this until it's about to be seen. Angular watches the placeholder with an intersection observer and pulls the chunk as it approaches the screen. There's a rule worth knowing that comes with it: on viewport needs something to observe, so the block needs a @placeholder (or an explicit element target). That constraint turned out to be a feature — it made me design the empty state instead of shipping a layout that jumps when the chart pops in.
What the block looks like, and what it changes
Here's the chart moved behind a deferred boundary, with a skeleton holding its space and a short delay before the spinner so a fast load never flashes one:
@defer (on viewport) {
<app-revenue-chart [data]="revenue()" />
} @placeholder {
<div class="chart-skeleton" aria-hidden="true"></div>
} @loading (after 100ms; minimum 300ms) {
<div class="chart-skeleton is-loading"></div>
}
The consumer writes nothing special — <app-revenue-chart> sits exactly where it always did, in the same template as the summary cards. The difference is entirely in what the browser is asked to load, and when:
initial load → summary cards + filter bar paint; chart chunk NOT requested
(charting library is absent from the first bundle)
scroll toward chart → chart chunk fetched, skeleton showing
chunk ready → <app-revenue-chart> renders in place
The initial bundle shrinks by the size of the charting library, first paint stops waiting on a feature below the fold, and the skeleton means the layout doesn't lurch when the real chart arrives. Same component, same template position. What changed is that its weight is now spent at the moment of use instead of the moment of load.
I want to be fair about what @defer is competing with, because it isn't the only way to hold code back. I could have reached for a manual dynamic import() and ngComponentOutlet, and it would work — but then I own the intersection observer, the loading state, the error state, and the placeholder timing by hand, which is a lot of plumbing to get subtly wrong. Route-level lazy loading, the tool I started with, is the right boundary when the weight sits behind navigation; it just wasn't my situation. And doing nothing was a real option too, until the chart's cost showed up in a Largest Contentful Paint number the business already watched. @defer won here because it makes the placeholder, the loading and error states, and the viewport trigger declarative — the parts I'd otherwise hand-roll and forget to test.
@defer Moves the Boundary to Where the Weight Is
Lazy loading a route answers a question about pages. This was a question about one component sitting out of sight inside a page it made everyone wait for. @defer let me draw the split where the cost actually was — around the chart — so the fold could paint on its own budget and the heavy work arrived on the user's schedule, not the bundler's. The feature didn't get smaller. It just stopped charging the entire page for something most people scrolled right past.
You can see the deferred chunk stay out of the initial load and arrive on scroll, source open beside it, on the live @defer demo.


