An observable does nothing until something subscribes. For years that gave Angular data loading a property we barely had to think about: expose a cold observable from a service, drop an async pipe in the template, and the request fired only when the view actually displayed it. A panel behind an @if that stayed false never subscribed, so it never fetched.
Then signals arrived, and resource() and rxResource() are a real upgrade: statuses, errors, cancellation, a value you can read anywhere. I have no desire to go back. But resources are eager by design: they start their loader from an effect at construction, so declaring a resource is fetching it. Somewhere in that upgrade, the laziness quietly disappeared.
What the framework nudges you toward instead is this component:
// this component exists to defer one fetch
class UserCard {
readonly user = rxResource({
params: () => 1,
stream: ({ params }) => api.user(params),
});
}
<!--
created: fetches.
destroyed: forgets.
shown again: fetches again.
-->
@if (showCard()) {
<user-card />
}
The component has no real job. It exists so that its lifecycle can play the part of the missing laziness: the only way to say "don't fetch this yet" is to push the resource down into a component that does not exist yet, behind an @if or inside a @defer block. The component lifecycle becomes your fetch trigger.
It works. That is exactly why it spreads. And every time it spreads, you pay three costs:
- The value dies with the component. Close the panel, open it again: refetch. The user pays for your architecture with a spinner, every single time.
- Data fetching scatters down the component tree. The route or service that owns the screen no longer owns its data. To find out what a page loads, you walk the tree and collect resources from leaf components.
- The codebase accumulates wrapper components. Each one is a file, a selector, a test, a name in your head. Their only feature is delaying one request.
@defer does not save you here. It defers a block of the view and its code chunk, which is great, but it says nothing about data. Nothing on the resources side defers the data itself. So we all keep reaching for the one deferral mechanism we have, the component lifecycle, and we keep paying those three costs.
For a long time I simply refused the pattern: I would rather fetch everything eagerly and pay for a few requests nobody looks at than scatter data fetching down the tree. The day I caught myself writing computed() wrappers whose only purpose was to create a resource on its first read, I stopped treating this as an architecture trade-off and started treating it as a missing primitive.
The fix is one line
Here is the proposal:
readonly user = resource({
lazy: true, // the only new line
params: () => this.userId(),
loader: ({ params }) => fetchUser(params),
});
// rxResource({ lazy: true, ... }) — inherited through its options, no other change
With lazy: true, the load effect is simply not created. Instead, the first read of any of the resource's signals starts the load. Templates read signals, so what renders is what fetches. The UserCard above becomes: declare the resource in the parent (or the route, or a service), read it wherever it shows. No wrapper.
Notice how each of the three costs falls away, and not by accident:
- The resource lives where you declared it, so its value outlives the panel that reads it. First visit fetches; revisit shows the kept value. No refetch, no spinner.
- The declaration moves up the tree, back to the route or service that owns the screen. The trigger stays down in the view, where it belongs, as a plain signal read.
- The wrapper components can be deleted. That is my favorite kind of feature: one that removes code.
The semantics: the decisions behind "the first read fetches"
A one-line option still has to behave correctly in every corner, and I had to make some calls. These are the ones worth knowing:
Every read wakes it, even inside untracked(). Suppose only untracked reads could stay silent, and your template does @if (r.hasValue()). The read returns false, nothing reactive changed, so no change detection cycle ever comes back to ask again. The template asked the question once, got "not yet", and the resource took "not yet" as permission to sleep forever: the guard that was supposed to reveal the data is the thing keeping it hidden. So: any read wakes the resource. A non-waking peek() is a natural follow-up if you truly need to look without touching.
Params changes while asleep are tracked but never fetched. The first read fetches once, with the latest params value. A user can change every filter in a form before opening the results panel and the network stays idle. Then one read, one request. I like this one because it falls out of thinking of params as state, not as events. The loader does not care how the params got to their current value, only what they are when someone finally wants the data. Once you frame it that way there is nothing to queue and nothing to replay.
Writes never wake it. set() on a never-read resource goes to the local status without running the loader. reload() defers the load to the next read.
Laziness composes. A computed() over a lazy resource is itself lazy. Chained resources (one's params reading another's value) stay asleep end to end, and one read at the end of the chain wakes them in order, never in parallel.
Once awake, it is exactly an eager resource. Same statuses, same errors, same cancellation, same SSR stability. The only documented caveat: combining lazy with id (SSR transfer) is discouraged, because the hydration window has usually closed by the first read.
Wasn't this already rejected?
Yes, mostly. Issue #58422 asked for a lazy option and the Angular team closed it. Their concern was legitimate: render-driven data fetching invites request waterfalls, and data is generally better lifted up to routes.
I agree with the concern. But look at where that closure left us. The workaround everyone uses instead, fetching tied to a component lifecycle, is render-driven fetching in its most fragile form, and it re-runs the waterfall on every component recreation. Each time the panel reopens, the whole chain of requests fires again. So the honest summary of the status quo is: Angular says "lift your data up", and then the only supported way to defer a fetch is to push it down.
Compare that with the wrapper component on the three points of the concern:
- Where is the declaration? Up the tree, at the route or service. The wrapper pushed it down.
- How many times does the trigger fire? Once, on first read. The wrapper refires on every recreation.
- Who owns the value? The service. It survives its readers. The wrapper's value died with the DOM.
The waterfall has nowhere to repeat. And it is strictly opt-in: eager resources are untouched. It also composes with @defer instead of competing with it: the deferred view brings the code, the lazy resource in the parent brings the data.
The proof, in numbers
I did not want this to be an opinion. Building custom signal primitives is how I usually attack this kind of gap, so I built the proposal three times, and scored the userland attempts against a parameterized suite of 39 observable behaviors: the sleeping rules above, plus everything a native resource must keep doing once awake.
| strategy | lines | contract |
|---|---|---|
| gate the params on a "shown" flag (the common workaround) | ~60 | 18/39 |
drive core's private loadEffect (2 private fields, do not ship) |
— | 34/39 |
| reimplement beside core, public API only | ~370 | 39/39 |
| the native option (this proposal) | ~90 in ResourceImpl | by construction |
A few things this table taught me. The "shown flag" gate looks like a cheap fix, but it only defers the first load and misses most of the sleeping rules. Reaching into core's privates gets you to 34/39 and a dependency on two private fields; its real value was as evidence, because the behaviors it passes are exactly the ones you get for free when the laziness lives where the effect is created. And the full reimplementation does pass everything, but it costs ~370 lines to reproduce what the native option does in ~90 inside ResourceImpl, because natively you are not fighting the design, you are just not creating one effect.
The native version is real code, not a sketch: branch feat/lazy-resource on my fork of angular/angular. One commit, a 500-line spec, docs. When lazy is set the load effect is not created; a small reactive pull node takes its place, pulled by value, status and error. On that branch, //packages/core/test/resource passes 91/91 (the new lazy suite plus every pre-existing spec, unchanged), rxjs-interop passes 76/76, and the public API goldens are green, all with the repo's own Bazel tooling. rxResource inherits the option through its existing options spread: zero interop code change.
If you would rather see it than believe a table, the interactive proposal page pins @angular/core 22.1.0 and applies the native diff to the published bundle with patch-package, so the option exists in your browser even though it does not exist in Angular. Every demo on the page runs the actual rxResource({ lazy: true }), with a per-demo network log showing every request that leaves and, more importantly, every request that does not. You can watch a parent-owned resource being read by a child, tabs that fetch on first visit and keep their value on revisit, params changing while the resource sleeps, a chain waking in order, and the full contract with reload, set, a 503 and the recovery. The same project runs on StackBlitz if you want to poke at the code.
Use it today, and help it land
Two things you can do, one for you and one for the feature.
For you: the 39/39 userland implementation is a single file with no private imports, published as a gist. It exports lazyResource (promise loader) and lazyRxResource (observable stream). Copy the file into your project, swap rxResource for lazyRxResource, and start deleting wrapper components this week.
For the feature: I have opened a feature request for the native option at angular/angular#70036.
If you have written the UserCard component above, in any of its forms, a thumbs-up helps; a comment describing your own wrapper-component pile helps more, because "who actually needs this" is the question the team will ask. The previous issue was closed on a reasonable concern; what it was missing, I suspect, is evidence of how many of us are living with the workaround. That part is on us.
Top comments (0)