← All guides

Angular @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.

1. Try it in 10 seconds (no signup)

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).

⚑ Skip the terminal: this link creates a live, seeded e-commerce backend (products, orders, customers, reviews) in the dashboard β€” real URL, no signup. Or import your own OpenAPI spec, db.json, CSV, Postman collection, or HAR.

2. The waterfall is strictly sequential: trigger β†’ chunk β†’ construct β†’ fetch

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.

3. 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.

4. @loading covers the JS download β€” it vanished the moment the real wait began

This 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 clickvisiblewhat's happening
68ms β†’ 2079ms@loading blockchunk downloading (2s)
2079ms β†’ 4632msthe component's own bare markup, zero rowsyour 2.5s data fetch β€” @loading is gone
4632msfive rowsdone

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.

5. On a fast network, @loading never renders at all

Measured (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.

6. 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.

7. @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.

8. What @error is actually for β€” we killed the chunk to prove it

Measured (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.

9. 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.

10. Silent opt-outs and dev-mode blindness

Two things we measured that produce no warning anywhere:

11. Reproduce every scenario on cue

All of the above is reproducible against the live demo API with two query params β€” no special backend:

scenarioURL
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 chunkthrottle 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"

12. Point it at your own schema

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.