httpResource aborts the request in flight and kills the search-as-you-type race

Two timelines of the same two requests. In the httpResource one the bar for the term an is cut short at 156ms of the 600ms it asked for and marked never delivered, ang starts at that moment and is the only answer delivered, and the box ends showing ang. In the plain subscribe one the an bar runs the full 600ms, ang is delivered and then overwritten, and the box ends showing an. Beside them, the server's own log and the list of assertions the demo makes
COMPANION REPO · LIVE FROM GITHUB

Angular 19.2 added httpResource. You hand it a function that builds a request out of signals, and it makes that request; when one of the signals it read changes, it makes the request again. The part I care about is what it does with the request that was still on the wire when the signal changed. It aborts it.

That single behaviour removes the classic search-as-you-type bug: the user types an, the server takes its time, the user types ang, the second answer comes back quickly and paints the right results, and then the first answer finally arrives and paints an's results over the top of them. Hardly anyone hits it in development, where the backend is on localhost and every response takes four milliseconds.

One thing before the code, because recommending an API without its status is how you get someone into trouble. httpResource shipped in 19.2 marked experimental and was still marked experimental for the whole of v21. It became public API in 22.0, and the declaration inside @angular/common@22.1.6 now carries @publicApi 22.0 where the v21 one carried @experimental 19.2. The API reference agrees, labelling it stable since v22.0. Everything below is measured on 22.1.6, which is the current release as I write this.

The shape of it

readonly term = signal('');

readonly results = httpResource<SearchResponse>(() => {
  const term = this.term().trim();
  if (!term) {
    return undefined;
  }
  return searchUrl(this.base, term, this.delays()[term] ?? 0);
});

readonly shown = computed(() => this.results.value()?.term ?? '(nothing)');

Apart from the template and the bookkeeping the demo needs to check itself, that is the component. There is no subscribe, no operator, and nothing to unsubscribe in ngOnDestroy. The function is reactive in the ordinary signals sense: it reads term, so setting term is what starts a request. Returning undefined means "no request right now", which is how the box stays quiet while it is empty.

(delays is a map from search term to how long the server should take over it. It only exists so the demo can script a race instead of hoping for one. In real code that line is just the URL.)

What happens to the request already in flight

The guide puts it in one sentence: "If a request is already pending, the resource cancels the outstanding request before issuing a new one." Underneath that there are two separate mechanisms, and it is worth knowing both, because only one of them is about the network.

The resource holds an AbortController for the load in progress. When the request function produces something new, the effect that runs the loader calls abort on that controller before it starts anything else. httpResource's loader registers a listener on the abort signal that unsubscribes from the HttpClient call, and unsubscribing is what tears the request down.

Which teardown runs depends on the backend, and the default moved in 22.0. A provideHttpClient() call with no features now gives you the fetch backend, whose teardown calls abort() on the AbortController it handed to fetch. withFetch() is deprecated in 22 with the note that it "is not required anymore", and withXhr() is how you ask for the older backend, whose teardown calls xhr.abort(). I measured both.

Then, separately, when a loader eventually does resolve, the resource checks whether its abort signal has fired or whether the request has moved on in the meantime, and if either is true it drops the result instead of writing it into the value signal. So a stale response cannot reach the view even in the small window before the abort has taken effect.

That second guard is exactly why a client-side test alone proves nothing here. A test that watches the component and sees only the newest value would pass whether or not anything was ever cancelled on the wire.

Making it fail on purpose

So the demo has a backend that keeps its own account. It is about 120 lines of Node with no dependencies, it takes its latency as a query parameter, and it records every request it was handed along with whether the connection went away before it had written a response:

res.on('finish', () => {
  answered = true;
});
res.on('close', () => {
  if (answered) return;
  clearTimeout(timer);
  record.aborted = true;
  record.endedAt = now();
});

The timings matter as much as the flag. an is typed first and the server is told to spend 600ms on it; ang is typed 150ms later and the server is told to spend 50ms. The older request is therefore the last one to answer. A test where the newest request also happens to be the quickest proves nothing, because the right answer would win by luck.

Here is what the server recorded during the httpResource run on the default fetch backend:

{"requests":[
  {"seq":0,"term":"an","delayMs":600,"startedAt":3538,"endedAt":3694,"aborted":true},
  {"seq":1,"term":"ang","delayMs":50,"startedAt":3694,"endedAt":3753,"aborted":false}
]}

an ran from 3538 to 3694 on the server's own clock, so it was cut off 156ms into a 600ms wait, in the same millisecond ang went out. The abort really does come first. And the component records every value its template binding took, which for that run was ['(nothing)', 'ang'] — the string an never appeared on screen at all, not even for a frame at the 600ms mark. The same run under withXhr() cut an off at 159ms and ended the same way.

ONE SEARCH BOX, TWO KEYSTROKES · THE SERVER TAKES 600ms OVER "an" AND 50ms OVER "ang"
httpResource
the older request is aborted when the newer one starts
search
showing: (nothing)
plain .subscribe()
both run to the end, and the slow one finishes last
search
showing: (nothing)
both boxes asked the same server for the same two things — only one of them stopped listening to the answer it had already replaced

The same screen, written the usual way

Three of the demo's other boxes point at the same server and differ only in how they make the call. One subscribes per keystroke with nothing flattening the calls (each of these snippets also pushes the value onto a render log so the test can see every value the binding took; that line is dropped here):

this.http
  .get<SearchResponse>(searchUrl(this.base, term, this.delays()[term] ?? 0))
  .subscribe((response) => {
    this.shown.set(response.term);
  });

Under those timings it finishes showing an, and the server log says "aborted":false with the full 600ms spent. The component's render log is ['(nothing)', 'ang', 'an'], which is the bug written out in order: the correct answer arrives, and then the stale one replaces it.

The second uses mergeMap, which is the version that fools people, because an operator went in and it looks like the race was dealt with. The demo picks the operator from a component input, so the two versions differ by exactly one term:

const flatten =
  this.operator() === 'switchMap' ? switchMap(request) : mergeMap(request);

this.typed
  .pipe(flatten, takeUntilDestroyed(this.destroyRef))
  .subscribe((response) => {
    this.shown.set(response.term);
  });

With mergeMap that behaves exactly like the plain subscribe. mergeMap runs every inner observable to completion, so both requests answer and the slow one still wins. The demo asserts that outcome as well rather than just describing it, because a broken example that nobody made fail is only an opinion.

What switchMap already does

Set that input to switchMap and the bug goes away. switchMap unsubscribes from the previous inner observable when a new value arrives, and unsubscribing from an HttpClient observable is the same teardown described earlier, so the request is aborted on the wire. The server log for the switchMap run has "aborted":true on the an request, same as the httpResource one, and that holds on both backends.

I want that stated plainly rather than buried, because httpResource is not doing something RxJS could not. The difference is that switchMap is a thing you have to remember, in every component, every time, and pick correctly from a family of four operators that all look interchangeable in the autocomplete list. httpResource has no version of itself that forgets. That is worth something, and it is a smaller claim than the one usually made for it.

Where it does not help

It has no debounce of its own. A change to any signal the request function reads re-runs that function, and unless the new result is undefined that is another request going out, even when the URL comes back byte-identical. The demo asserts that one by setting the term to ang and then to ang , and counting two requests at the server. HttpResourceOptions in 22.1.6 has the same five fields it had in 21 (parse, defaultValue, injector, equal, debugName) and none of them is a delay.

What did change in 22.0 is that the framework now ships something to put in front of it. debounced() from @angular/core takes a source function and a wait, and returns a Resource whose value only catches up once the source has stopped changing, so the request function reads .value() rather than the signal itself:

readonly term = signal('');
readonly settled = debounced(() => this.term(), 300);

readonly results = httpResource<SearchResponse>(() => {
  const term = this.settled.value()?.trim();
  if (!term) {
    return undefined;
  }
  return searchUrl(this.base, term, this.delays()[term] ?? 0);
});

The demo measures that rather than assuming it. Four keystrokes typed with a gap between them put four requests in the server's log; the same four typed into the debounced box put nothing in the log while the window is open, and one request for the final term once it closes. The wait can be a function returning a promise instead of a number, which is what the test uses so that "the typing stopped" is a gate it opens rather than a duration it sleeps through. One caveat to carry: debounced is marked @experimental 22.0 in @angular/core@22.1.6, which is the stage httpResource itself sat in from 19.2 in February 2025 until 22.0 this June.

It is also the wrong tool when you need every answer rather than the latest one. Anything that appends — an infinite scroll, a log tail, a set of independent lookups you want all of — wants mergeMap or concatMap semantics, and a resource will happily cancel work you meant to keep.

And the guide is direct about writes: "Avoid using httpResource for mutations like POST or PUT. Instead, prefer directly using the underlying HttpClient APIs." I would go further and call it a hard rule. A resource is a read of derived state; cancelling a read is free, and cancelling a POST halfway through is a support ticket.

One last caveat that is not Angular's fault. An abort closes the connection; it does not undo whatever the server already started. Whether the work actually stops depends on the server honouring the disconnect, which in ASP.NET Core means a CancellationToken that somebody actually passed down. The demo's server does honour it, because I wrote it to. Plenty do not.

The demo is at github.com/gdhami-net/http-resource-demo. npm run check starts the recording server, runs the assertions against it, and stops it again. The six about the race run once on each backend:

 ✓ the default backend (fetch) > httpResource: the abandoned request never reaches the view
 ✓ the default backend (fetch) > httpResource: the abort reaches the server, which stops early
 ✓ the default backend (fetch) > plain subscribe: the same timings put the stale answer on screen
 ✓ the default backend (fetch) > switchMap: the hand-rolled fix aborts the same request httpResource does
 ✓ the default backend (fetch) > mergeMap: an operator that is not switchMap leaves the race exactly where it was
 ✓ the default backend (fetch) > httpResource: a source change that produces the same URL still refetches
 ✓ the XHR backend, via withXhr() > httpResource: the abandoned request never reaches the view
 ✓ the XHR backend, via withXhr() > httpResource: the abort reaches the server, which stops early
 ✓ the XHR backend, via withXhr() > plain subscribe: the same timings put the stale answer on screen
 ✓ the XHR backend, via withXhr() > switchMap: the hand-rolled fix aborts the same request httpResource does
 ✓ the XHR backend, via withXhr() > mergeMap: an operator that is not switchMap leaves the race exactly where it was
 ✓ the XHR backend, via withXhr() > httpResource: a source change that produces the same URL still refetches
 ✓ debounced() in front of the resource > the same four keystrokes without it put four requests on the wire
 ✓ debounced() in front of the resource > a burst of keystrokes reaches the server as one request

 Test Files  1 passed (1)
      Tests  14 passed (14)

The tests run under the Angular 22 unit-test builder with the Vitest runner in Node with jsdom, against a real local socket rather than a mocked backend. On the XHR path the requests go through jsdom's XMLHttpRequest; on the default path Angular's fetch backend calls globalThis.fetch directly, which here is Node's own built-in fetch (jsdom 27 defines no fetch of its own), so what is exercised there is undici and not a browser's network stack. I have not repeated any of it in a real browser.