← All guides

Angular resource(), measured β€” the untracked loader, the dropped race, and the value() that throws

resource() is Angular's general-purpose async primitive: params is a reactive function, loader is your async code β€” fetch, a gRPC client, IndexedDB, anything that returns a promise. That split is the whole design, and it's also where the sharp edges live: only params is tracked. A signal read inside the loader is silently invisible to reactivity, fetch's no-reject-on-500 behavior walks right in, and value() throws when the loader failed.

Everything below was reproduced and measured in a real Chrome on Angular 22.1 (stock ng new blueprint, resource is @publicApi 22.0 in the shipped types) with an instrumented window.fetch β€” two full runs, identical counts. The numbers are observed, not paraphrased from docs.

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=3"

That URL is the API for every snippet below. CORS is open (Access-Control-Allow-Origin: *), so it works from localhost:4200, StackBlitz, CI, anywhere. When you want your own schema it's one curl or one click β€” see Β§12.

⚑ 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. resource() imports from @angular/core β€” no provideHttpClient() needed, since the loader is your own fetch. If your loader is just "GET this URL", httpResource is the shorthand β€” measured separately here.

2. It fetches on construction β€” and undefined params is the off switch

resource() is eager: the loader runs when the class field initializes, not when the template first reads the resource. We declared three resources in one component and rendered exactly one section behind @if:

resA = resource({ params: () => ({}), loader: () => fetchProducts() });
resB = resource({ params: () => ({}), loader: () => fetchOrders() });   // hidden
resC = resource({ params: () => ({}), loader: () => fetchCustomers() }); // hidden

Measured: 3 network requests on page load β€” both hidden sections fetched. The escape hatch is a convention worth memorizing: if params returns undefined, the loader never runs.

q = signal<string | undefined>(undefined);
res = resource({
  params: () => this.q() ? { q: this.q()! } : undefined,   // idle until q is set
  loader: ({ params }) => searchProducts(params.q),
});

Measured: status() stayed 'idle' with 0 requests for as long as the signal was unset; one q.set('night') later β€” exactly 1 request, five rows. That's your "don't fetch until the user acts" pattern, no boolean flags.

3. The happy path: read signals in params, receive them in the loader

import { Component, signal, resource } from '@angular/core';

@Component({ /* ... */ })
export class ProductList {
  page = signal(1);
  res = resource({
    params: () => ({ page: this.page() }),          // tracked: reads the signal
    loader: async ({ params }) => {
      const r = await fetch(`https://mockbird.mockbird.workers.dev/m/demo/products?page=${params.page}&limit=5`);
      if (!r.ok) throw new Error('HTTP ' + r.status);
      return r.json() as Promise<Product[]>;
    },
  });
}
// template: @for (p of res.value() ?? []; track p.id) { 
  • {{ p.name }}
  • }

    Measured: exactly 1 request on load; page.set(2) produced exactly 1 refetch and the rows swapped. The loader receives the params value β€” it never needs to touch a signal.

    4. The loader is untracked β€” a signal read inside it never refetches

    The headline gotcha. It looks equivalent to Β§3. It is not:

    // THE BUG: no params β€” "I'll just read the signal in the loader"
    res = resource({
      loader: async () => {
        const r = await fetch(`${API}/products?page=${this.page()}&limit=5`); // untracked read
        return r.json();
      },
    });

    Measured: we clicked "next page" twice β€” the template dutifully rendered "page: 3", and the network log showed 1 request, total. The list stayed frozen on page-1 rows. Zero console warnings, zero errors. Angular runs loaders in an untracked context on purpose (so reading other state mid-load can't cause accidental refetch loops), but the flip side is this silent freeze: every value the request depends on must flow through params. If the loader mentions this.someSignal(), that's the bug.

    5. Stale responses are dropped even if you ignore abortSignal β€” cancellation is opt-in

    We made the first request slow (?mock_delay=2500, returning page-1 rows) and switched params 500ms in to a fast request returning a different row. First, a loader that ignores abortSignal:

    raceRes = resource({
      params: () => ({ q: this.q() }),
      loader: async ({ params }) => {           // no abortSignal in sight
        const r = await fetch(urlFor(params.q));
        return r.json();
      },
    });

    Measured: the fast row rendered; 2.5s later the slow response landed on the network (2 completed requests, 0 aborts) β€” and the UI didn't budge. resource() discards resolutions from outdated loader runs. Correctness is built in; what you waste is bandwidth and server work. Passing the signal fixes that too:

    loader: async ({ params, abortSignal }) => {
      const r = await fetch(urlFor(params.q), { signal: abortSignal });  // real cancellation
      return r.json();
    },

    Measured: same scenario β€” the slow request was aborted at the network layer (we captured the abort event), 1 completed response, same correct UI. And the control group, effect() + fetch with no protection at all: the fast result rendered first, then the stale slow response landed and clobbered it back. That's the bug class resource() deletes β€” ?mock_delay makes it reproducible on demand instead of "sometimes, on hotel wifi".

    6. fetch doesn't reject on a 500 β€” and your loader is now the error handler

    With httpResource, HTTP errors become error states automatically. With resource(), you own that logic, and fetch's oldest trap applies: it only rejects on network failure, never on HTTP status. This loader has no r.ok check:

    loader: async () => {
      const r = await fetch(`${API}/products?limit=3&mock_status=500`);
      return r.json();   // parses the ERROR BODY as data
    },

    Measured: status() β†’ 'resolved'. value() β†’ {"error": "simulated 500 error (mock_status)"}, rendered as if it were data. No error state, no throw β€” the only trace was the browser's own network-tab console line. Every custom loader needs the two-liner from Β§3: if (!r.ok) throw new Error('HTTP ' + r.status). Simulate it in one query param (?mock_status=500) and watch what your template does with an error object shaped nothing like Product[].

    7. value() throws in the error state β€” and defaultValue won't save you there

    Once the loader throws, the resource enters status() === 'error': error() returns your Error('HTTP 500'), hasValue() β†’ false β€” and value() throws:

    Error: Resource is currently in an error state (see Error.cause for details): HTTP 500

    A template that reads res.value() unguarded doesn't render an empty list on failure β€” it throws during render. Guard with hasValue() (it narrows the type) or branch on status().

    The defaultValue subtlety, measured: with resource({ defaultValue: [] }), value() mid-load returned [] instead of undefined β€” nice for templates. But in the error state, value() threw exactly the same message. defaultValue covers idle and loading; it does not make failures quiet. If you want "keep showing something on error", read hasValue() ? res.value() : fallback yourself.

    8. reload() keeps your rows β€” a param change blanks them

    Two loading states look identical in a spinner but behave differently under your data. Measured mid-flight (?mock_delay=1500 makes the gap visible):

    status()hasValue()rows on screen
    After reload()'reloading'true5 β€” previous data stays
    After a param change'loading'false0 β€” list blanks

    A refresh button flickers nothing; a page change flashes empty unless you handle it (keep the previous rows in a signal, or branch on status()). Same asymmetry as httpResource β€” it's the resource contract, not the HTTP layer.

    9. No .headers() here β€” surface X-Total-Count from the loader yourself

    httpResource exposes response headers as a signal; resource() gives you whatever your loader returns β€” so return the header with the rows:

    pagerRes = resource({
      params: () => ({ page: this.page() }),
      loader: async ({ params }) => {
        const r = await fetch(`${API}/products?page=${params.page}&limit=10`);
        const rows = await r.json() as Product[];
        return { rows, total: Number(r.headers.get('X-Total-Count') ?? 0) };
      },
    });

    Measured: a load-more accumulator went 10 β†’ 20 β†’ 30 rows in exactly 3 requests with 0 duplicate ids, with the total read from X-Total-Count (CORS-exposed on the demo β€” many real APIs forget Access-Control-Expose-Headers and this silently reads null; the mock behaves like the real thing so you catch it now).

    10. set() is a free optimistic-update primitive β€” status becomes 'local'

    resource() returns a writable resource. Write to it and the framework tracks that the value no longer came from the server:

    renameFirst() {
      const cur = this.res.value();
      this.res.set([{ ...cur[0], name: 'Saving…' }, ...cur.slice(1)]);  // optimistic
    }

    Measured: after set(), the edited row rendered immediately, status() β†’ 'local', and 0 network requests fired. One reload() later: server truth back on screen, 'resolved', exactly 1 request. Pair it with a real write β€” the demo's POST/PATCH actually persist, so set()-then-PATCH-then-reload() round-trips against reality instead of write theater.

    11. Deterministic retry tests β€” reload() against a scripted outage

    Retry logic tested against a healthy API is untested. ?mock_seq serves an exact status script:

    curl -s -o /dev/null -w "%{http_code}\n" \
      "https://mockbird.mockbird.workers.dev/m/demo/products?limit=1&mock_seq=503,503,200&mock_seq_key=me1"

    Measured: a resource() whose loader throws on !r.ok, pointed at that URL: initial load β†’ 'error' with HTTP 503; reload() β†’ 503 again; second reload() β†’ 'resolved' with data. Exactly 3 requests. On the shared demo the sequence counter is scoped per client IP (and mock_seq_key isolates parallel specs), so the curl above starts fresh for you. Statuses β‰₯400 are simulated before processing, so a failed write is never half-applied.

    12. Get your own API (10 seconds, no signup)

    curl -X POST https://mockbird.mockbird.workers.dev/api/projects \
      -H "Content-Type: application/json" \
      -d '{"preset": "ecommerce"}'

    The response contains your project URL and admin key. Presets: blog, ecommerce, saas, payments β€” or define your own resources and field types, import an OpenAPI spec / db.json / CSV / Postman collection / HAR, and swap the base URL in the snippets above. Ship day is one environment token change.

    13. Beyond this guide

    14. Honest comparison

    ToolGood atWhere this differs
    angular-in-memory-web-apiThe classic Angular mock backend: intercepts HttpClient in-processIt intercepts HttpClient β€” a resource() loader using raw fetch bypasses it entirely. A hosted mock serves any client your loader is written in, and exercises real CORS/abort/header behavior (Β§5, Β§9 are invisible in-process).
    MSW (Mock Service Worker)In-process request interception for unit tests; intercepts fetch tooMSW mocks live inside each test runner. A hosted mock is the same URL for your browser, CI, a StackBlitz repro, and a teammate's machine with zero setup. Use MSW for unit tests, a hosted mock for everything shared.
    json-serverLocal full-featured fake REST; huge ecosystemNeeds Node running on every machine that wants the API. Mockbird speaks the same conventions (_page, _limit, db.json import/export) but is hosted β€” and adds auth simulation, failure injection (mock_seq, mock_status, mock_delay), snapshots.

    Bias disclosure: this comparison is written by the Mockbird side. The measurements above, though, are just measurements β€” rerun them in your own Chrome with the snippets as written.