@defer and your data, measured β the loading block that never shows, the error block that never fires@defer lazy-loads a chunk of your template: the JavaScript for everything inside the block downloads when a trigger fires, not at startup. But most deferred components immediately fetch something β and that's where the mental model quietly breaks. @loading, @error, and prefetch are about the JS chunk, not your data. Your spinner never renders, your error block ignores a 500, and your "prefetched" section still hits the network on reveal.
Everything below was measured on Angular 22.1 in a real Chrome β on a production ng build (dev mode actively hides chunk behavior, Β§10), with an instrumented window.fetch, per-component construction logs, and a static server that could throttle or kill the lazy chunk on demand. Two full runs of every suite, identical results. The deferred component fetches five products via httpResource from a live mock API.
A shared, self-resetting demo project is live right now:
curl "https://mockbird.mockbird.workers.dev/m/demo/products?limit=5"
That URL is the API every measurement below hit. CORS is open (Access-Control-Allow-Origin: *), so it works from localhost:4200, StackBlitz, CI, anywhere. It also honors ?mock_delay=2500 and ?mock_status=500 β which is how we made the data slow and broken on cue (Β§11).
The harness component:
@Component({
selector: 'deferred-products',
template: `...`,
})
export class DeferredProducts {
res = httpResource<Product[]>(() =>
'https://mockbird.mockbird.workers.dev/m/demo/products?limit=5');
}
<button #btn>load section</button>
@defer (on interaction(btn)) {
<deferred-products />
} @placeholder {
<p>placeholder</p>
}
Measured (prod build): before the click β zero chunk requests, zero data requests, zero constructions. Click at t=1479ms β lazy chunk requested at t=1534 β component constructs β data fetch starts at t=1565. Each stage waits for the previous one. With a 2s chunk and a 2.5s response we measured first rows at 4.6s after the click β the two delays add, they never overlap. Remember that when you budget a "lazy" section on 3G: it pays chunk plus data, in series.
Triggers also fire once. In the on viewport scenario, scrolling the block into view fetched (1 request); scrolling away and back did nothing (still 1). Deferred content, once in, is ordinary content.
when is a one-way latch β it never un-renders@defer (when show()) {
<deferred-products />
} @placeholder { <p>hidden</p> }
Measured: show.set(true) β chunk + construct + 1 fetch, five rows. show.set(false) β the UI kept showing all five rows while the template printed cond: false next to them. No teardown, no placeholder back, no console note. show.set(true) again β no refetch (total stays 1). when answers "when may this load?", not "should this be visible?" β if you need toggling, wrap the defer block's content in @if, or accept that the data shown is from the first and only load.
@loading covers the JS download β it vanished the moment the real wait beganThis is the headline. We gave the block a loading state, slowed the chunk to 2s with a throttling server, and slowed the data to 2.5s with ?mock_delay=2500:
@defer (on interaction(btn)) {
<deferred-products /> <!-- fetches with mock_delay=2500 -->
} @placeholder { <p>placeholder</p> }
@loading { <p>loadingβ¦</p> }
Measured timeline (prod build, sampled every 60ms):
| t after click | visible | what's happening |
|---|---|---|
| 68ms β 2079ms | @loading block | chunk downloading (2s) |
| 2079ms β 4632ms | the component's own bare markup, zero rows | your 2.5s data fetch β @loading is gone |
| 4632ms | five rows | done |
The loading block disappeared exactly when the slow part started. If the deferred component doesn't render its own pending state (res.isLoading(), a skeleton, anything), users stare at an empty section for the entire data fetch and your carefully written @loading spinner is nowhere in sight.
@loading never renders at allMeasured (localhost chunk, ~10ms): across a 2.7-second total load β slow data, fast chunk β the @loading block was never visible in a single sample. Zero warnings. So on localhost (and any decent connection, where a 1.3kB chunk is a rounding error) a spinner placed in @loading is dead code you'll never see in development and your users will rarely see in production β while the wait it doesn't cover, the data fetch, is the one that's actually long. Put the skeleton inside the deferred component; treat @loading as a slow-network nicety.
minimum postpones your data fetch@loading (after 100ms; minimum 1500ms) is the documented anti-flicker recipe. What the docs don't say: the swap β component construction and your fetch β waits for minimum to elapse.
Measured (0.5s chunk): plain @loading β data fetch at 1370ms, rows at 3211ms. With after 100ms; minimum 1500ms β loading visible 155β1697ms, data fetch waits until 2514ms, rows at 4284ms. The anti-flicker minimum bought 1.1 seconds of extra total latency, because the fetch can't start until the component exists and the component can't exist until the loading block has had its minimum. @placeholder (minimum 1500ms) behaves the same: with a timer(200ms) trigger, the first data request left at exactly the 1527ms swap, not at 200ms. And the after 100ms part works as advertised β with a fast chunk the loading block correctly never flashed.
@error does not catch your fetch's 500@defer (on interaction(btn)) {
<deferred-products /> <!-- fetches with mock_status=500 -->
} @placeholder { <p>placeholder</p> }
@error { <p>DEFER ERROR BLOCK</p> }
Measured (dev and prod, identical): the data endpoint returned HTTP 500 β the deferred block swapped in successfully, the component rendered its own error state (httpResource status 'error', HTTP 500), and the @error block never appeared. The only console line was the browser's own network log. From @defer's perspective a component whose data died is a perfectly healthy component. HTTP error UI belongs inside the component β res.error(), an interceptor, an error boundary β not in @error.
@error is actually for β we killed the chunk to prove itMeasured (prod build, chunk request aborted at the network layer): click β chunk fails (net::ERR_FAILED) β @error block visible, component never constructed, data fetches: 0. That's the real contract: @error fires when the JavaScript can't be delivered β offline users, flaky mobile, and the classic post-deploy hash mismatch where old HTML asks for chunks that no longer exist. Worth having, worth testing (it's one page.route(chunk, abort) in Playwright) β just not a data-error handler.
prefetch downloads JavaScript, not data@defer (on interaction(btn); prefetch on idle) { <deferred-products /> }
Measured (prod build): at idle, ~180ms after load, the lazy chunk downloaded β but zero data requests and zero constructions. On click: no second chunk request (cached), and only then the data fetch. So a prefetched section still greets its user with the full data wait. If the reveal should be instant, prefetch the data too β kick the request off in a parent service when you kick off the prefetch, or accept the fetch-on-reveal.
Two things we measured that produce no warning anywhere:
@defer and eagerly elsewhere is not split. Our prod build emitted a lazy chunk for the defer-only component (1.27kB, listed under "Lazy chunk files") β but the component that also appeared once outside a defer block was folded into the initial bundle. The defer block still "works" (constructs a second instance on trigger); the bundle-size benefit silently evaporates.ng serve loads deferred modules at bootstrap. In every dev-mode scenario the deferred component's module was fetched ~400ms into page load, before any trigger β only construction was deferred. So chunk timing, @loading visibility, chunk failures, and prefetch behavior are all invisible (or misleading) in dev. Verify them against ng build output, like this guide did.All of the above is reproducible against the live demo API with two query params β no special backend:
| scenario | URL |
|---|---|
| slow data fetch (waterfall, Β§4) | /m/demo/products?limit=5&mock_delay=2500 |
| HTTP 500 from the fetch (Β§7) | /m/demo/products?limit=5&mock_status=500 |
| fails twice then recovers (retry logic) | /m/demo/products?mock_seq=503,503,200&mock_seq_key=defer1 |
| slow chunk | throttle the chunk file in DevTools, or page.route() it in Playwright |
| dead chunk (Β§8) | page.route(/chunk-.*\.js/, r => r.abort()) after first paint |
curl "https://mockbird.mockbird.workers.dev/m/demo/products?limit=5&mock_status=500"
The demo project is shared and resets daily. Your own persistent mock backend is one curl:
curl -X POST "https://mockbird.mockbird.workers.dev/api/projects" \
-H "content-type: application/json" \
-d '{"preset":"ecommerce"}'
That returns a project URL with seeded products/orders/customers/reviews, full CRUD that persists, filtering/pagination/relations, plus OpenAPI, TypeScript types, and GraphQL exports β and every simulation param used above. Free, no signup. Or create it in one click.