resource(), measured β the untracked loader, the dropped race, and the value() that throwsresource() 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.
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.
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.undefined params is the off switchresource() 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.
params, receive them in the loaderimport { 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.
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.
abortSignal β cancellation is opt-inWe 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".
fetch doesn't reject on a 500 β and your loader is now the error handlerWith 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[].
value() throws in the error state β and defaultValue won't save you thereOnce 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.
reload() keeps your rows β a param change blanks themTwo 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' | true | 5 β previous data stays |
| After a param change | 'loading' | false | 0 β 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.
.headers() here β surface X-Total-Count from the loader yourselfhttpResource 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).
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.
reload() against a scripted outageRetry 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.
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.
httpResource is the URL-only shorthand β auto error states, .headers(), auto-abort. Its own measured sharp edges (including 81 fetches in 5s from one effect) are at Angular httpResource, measured.GET /m/<project>/types.ts generates interfaces from your live schema (?format=zod for Zod) β a typed loader return for free.POST /m/<project>/auth/login returns a real signed JWT for any email+password β test 401 flows in your loader without a backend (guide).beforeEach β deterministic Karma/Playwright runs (guide).@defer's @loading/@error/prefetch are about the JS chunk, not your data β the loading block that never shows and the error block that ignores a 500 are measured at Angular @defer and your data, measured.| Tool | Good at | Where this differs |
|---|---|---|
| angular-in-memory-web-api | The classic Angular mock backend: intercepts HttpClient in-process | It 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 too | MSW 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-server | Local full-featured fake REST; huge ecosystem | Needs 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.