Route-level lazy loading has been in Angular for years, but it only works at the granularity of a whole page. A product page with a heavy reviews widget, a chart library and a map still ships all three in the route’s chunk. The @defer block, available since Angular 17 and stable in the versions you’ll meet in interviews today (19 and 20), gives you lazy loading at the level of a template region instead. The compiler pulls the components, directives and pipes used inside the block into a separate JavaScript file and loads that file only when a trigger fires.
That one sentence hides most of what interviewers actually probe: which dependencies really get split out, what the trigger controls, and what the user sees before, during and after the load.
What the compiler actually moves into the lazy chunk
A dependency inside @defer is split out only when two conditions hold. It has to be standalone, and it can’t be referenced anywhere outside a defer block in the same file. A @ViewChild(ReviewsPanel) query on the parent component counts as a reference, and so does using <app-reviews-panel> once more elsewhere in the template. In either case the compiler keeps the class in the eager bundle, and your @defer block still works, it just doesn’t save any bytes. Nothing warns you about it at build time.
The same thing happens across files. If a component that’s loaded eagerly somewhere else in the app also imports ReviewsPanel, the code is already in a chunk the browser downloaded at startup. The defer block will still delay rendering, but the network win is gone. Barrel files cause a quieter version of this: importing through an index.ts that re-exports many things can make the bundler treat the whole barrel as eagerly needed. If you expect a lazy chunk and don’t see one in the build output, import the component from its own file and check again.
Transitive dependencies don’t have to be standalone. If ReviewsPanel uses a pipe declared in an NgModule, that pipe travels into the lazy chunk with it.
The sub-blocks and what each one costs
A defer block can have up to three companions:
@placeholderrenders before the trigger fires. Its dependencies are loaded eagerly, so keep it to plain markup or very small components.@loadingreplaces the placeholder once fetching starts. It’s also eager.@errorrenders if the chunk fails to load, for example when a deploy has removed the old file and a long-lived tab requests it.
Both @placeholder and @loading accept timing parameters that exist to stop flicker. @placeholder (minimum 500ms) keeps the placeholder on screen for at least half a second after it first appears. @loading (after 100ms; minimum 1s) means: don’t show the spinner at all if the chunk arrives within 100 ms, and if you do show it, keep it for a full second. On a fast connection the user sees placeholder then content, with no spinner flash. On a slow one they see a spinner that doesn’t blink away the instant it appears.
Picking a trigger
Without any trigger, @defer fires when the browser goes idle (Angular uses requestIdleCallback under the hood). The other on triggers are:
on viewportuses anIntersectionObserver. By default it watches the placeholder, which must then have a single root element. You can also pass a template reference:on viewport(reviewsAnchor).on interactionfires on click or keydown on the placeholder or a referenced element.on hoverfires on mouseover or focusin.on immediatefires as soon as the surrounding template has rendered.on timer(2s)fires after a fixed delay.
There’s also when someSignal(), which takes any expression. It’s a one-way switch: once it has been truthy and the block has loaded, setting it back to false does not return to the placeholder. If you need content to come and go, wrap the defer block in an @if.
Several triggers in one block are combined with OR: @defer (on viewport; on timer(5s)) loads on whichever happens first.
Fetching early, rendering late
prefetch takes the same trigger syntax but only downloads the chunk. @defer (on interaction; prefetch on idle) downloads the code when the browser has spare time, then renders instantly when the user clicks. That split is the answer to the common objection “lazy loading makes the click feel slow”.
A worked example: the reviews section on a product page
Say the product page shows title, price and gallery above the fold, and reviews with a rating histogram (which pulls in a charting library) far below. Here’s a template that keeps the critical path small:
<app-product-hero [product]="product()" />
<app-price-box [price]="product().price" />
<h2 #reviewsAnchor>Customer reviews</h2>
@defer (on viewport(reviewsAnchor); prefetch on idle) {
<app-reviews-panel [productId]="product().id" />
} @placeholder (minimum 300ms) {
<div class="reviews-skeleton" style="min-height: 480px"></div>
} @loading (after 150ms; minimum 600ms) {
<div class="reviews-skeleton is-loading" style="min-height: 480px"></div>
} @error {
<p>Reviews couldn't load. <a href="">Refresh the page</a> to try again.</p>
}Walk through what happens on a typical visit:
- The route chunk loads.
ReviewsPaneland the chart library aren’t in it, so first paint arrives sooner. - The skeleton renders with a fixed
min-height, roughly matching the real panel. That reserved height is what stops cumulative layout shift when the panel swaps in. - When the main thread goes idle,
prefetch on idledownloads the reviews chunk in the background. Nothing renders yet. - When the heading scrolls into view, the viewport trigger fires. The chunk is already cached, so the panel renders almost immediately and the
after 150msrule means the loading skeleton never appears. - If the user scrolls fast on a slow connection before prefetch finishes, they’ll see the loading state for at least 600 ms rather than a flicker.
Notice the trigger watches reviewsAnchor rather than the placeholder. The heading sits slightly above the skeleton, so loading starts a moment before the reviews area itself is visible.
Gotcha
If
ReviewsPanelis also referenced by a@ViewChildin this component, or rendered anywhere else in this template outside a defer block, it will be bundled eagerly and steps 1 and 3 above stop being true.
What the server renders
Interviewers who know SSR like this one. During server rendering and static generation, defer blocks render their @placeholder (or nothing at all) and triggers never run. The client then hydrates the placeholder and activates the triggers. So if you wrap product descriptions or reviews in @defer for performance, that text won’t be in the HTML that crawlers and slow devices receive first.
The fix is incremental hydration, added in Angular 19 and stable in 20. With provideClientHydration(withIncrementalHydration()), a block such as @defer (hydrate on viewport) renders its full content on the server and ships it as HTML, but the JavaScript for that region is downloaded and hydrated only when the hydrate trigger fires. You get indexable content and a lazy chunk at the same time. (From Angular 22, provideClientHydration() turns incremental hydration on by default, so the extra feature function is only needed on 19 to 21.)
Testing defer blocks
In TestBed, defer blocks play through on their own by default, much as they would in a browser. To check each state explicitly, configure manual behaviour and step through it:
TestBed.configureTestingModule({ deferBlockBehavior: DeferBlockBehavior.Manual });
const fixture = TestBed.createComponent(ProductPage);
fixture.detectChanges();
const [reviews] = await fixture.getDeferBlocks();
expect(fixture.nativeElement.textContent).not.toContain('Average rating');
await reviews.render(DeferBlockState.Complete);
expect(fixture.nativeElement.textContent).toContain('Average rating');That snippet is illustrative (it assumes a ProductPage component and Jasmine or Jest matchers), but getDeferBlocks(), render() and the DeferBlockState values (Placeholder, Loading, Complete, Error) are the real APIs. Testing the @error state this way is much easier than simulating a failed chunk request.
Interview question: the slow dashboard
“Our analytics dashboard has four KPI tiles at the top, then a large heatmap, then a data table with export to Excel. Lighthouse says the main bundle is 1.4 MB. Where would you use @defer, and what would you check afterwards?”
Answer. Leave the KPI tiles eager, because they’re the first thing the user reads and deferring them would add a request before the most important content appears. Wrap the heatmap in @defer (on viewport; prefetch on idle) with a placeholder of the same height, so the charting library leaves the main bundle and arrives before most users scroll to it. The table’s Excel export library is a better fit for on interaction on the export button, because many users never export at all; keep the table itself eager if it’s visible without scrolling, otherwise defer it on viewport with a different trigger from the heatmap so the two don’t load at the same moment.
Then verify, because the deferral can silently fail. Run ng build and confirm new lazy chunk files appear for the heatmap and export code. Search the dashboard component for @ViewChild queries or other template references to those components. Open the Network panel and check the chunks are requested when you scroll or click, not at startup. Finally, if the app uses SSR, remember the heatmap will be a placeholder in the server HTML, which is fine for a logged-in dashboard but would matter on a public page.