Back to News & Insights
Web Development August 27, 2026 Β· 9 min read

validateHttp() Has No Async Machinery: A Trace From Signal Forms Down to fetch() πŸ”πŸš€

Let's be honest: async validation is the part of any forms library where you brace yourself....

validateHttp() Has No Async Machinery: A Trace From Signal Forms Down to fetch() πŸ”πŸš€

Let's be honest: async validation is the part of any forms library where you brace yourself. Debouncing, cancelling the request the user just invalidated by typing another character, keeping a "checking..." spinner honest, not letting a slow response overwrite a fast one. Every library that has ever done this has grown a pile of bespoke machinery for it.

So when Signal Forms shipped validateHttp() and it just worked, I wanted to see the pile. I opened the source expecting a few hundred lines of async bookkeeping, and instead found a function whose entire body is a single call to something else.

That turned into a trace all the way down, from a form field to the line where bytes actually leave the browser. Six layers, and only two of them add anything you could call new async machinery.

βœ… Availability: validateHttp() is @publicApi 22.0, stable. Every source reference in this article is pinned to the v22.1.1 tag, so the line numbers stay valid even as main moves.

The usage is unremarkable, which is the point. You declare that a field validates against an endpoint, and you're done:

Sync validators run first, the request waits until they pass, field().pending() is true while it's in flight, and typing again cancels the previous call. If you've read Part 3 of my Signal Forms series, that's the behaviour contract you already know. The question here is who implements it.

That's it. No await, no subscription, no cancellation logic. The URL function you wrote becomes the params of something, and httpResource() gets handed over as a factory. Out of the whole file, that factory is the only line that does anything HTTP-related; everything else naming HTTP is an import, a type or a doc comment.

validateasync.ts is where I expected the pile to be. It isn't there either. Three behaviours, each one built from something that already existed.

Sync-before-async gating is just returning undefined. The computation that feeds the resource's params does this:

A resource() whose params computation returns undefined sits in idle and never calls its loader β€” loadEffect() returns early as soon as it sees extRequest.request === undefined. That's documented public behaviour, not a forms detail. The docs promise that async validation runs only after all synchronous validation passes. Nobody implemented that promise here. It falls out of a rule that was already there.

pending() is a resource status, read through a validator. The async error rule is a switch (lightly condensed here β€” the real one wraps each result in addDefaultField() to attach it to the field):

Six resource statuses collapse into four form outcomes. field().pending() holds no state of its own, it's a projection of res.status().

debounced() is a public (still experimental) @angular/core primitive, and Ι΅chain is the internal side of ctx.chain(), the resource-composition helper you reach through a params context. The file does declare export function chain(), but it carries no @publicApi marker and is not surfaced on @angular/core's public entry point, which is why the forms code reaches it through the Ι΅-prefixed alias rather than importing it by name. The forms team is chaining a debounced resource into a loader's params exactly the way you would chain any two resources in your own code. Worth noticing that they're dogfooding an experimental API in a stable feature.

httpResource() extends ResourceImpl and doesn't override any of the loading logic. It passes in a loader that subscribes to HttpClient, and it adds three signals:

| Signal | What it is | Resets on request change? | |---|---|---| | progress | linkedSignal sourced from extRequest | Yes, by the graph | | statusCode | linkedSignal sourced from extRequest | Yes, by the graph | | headers | computed over a linkedSignal, gated on status | Yes, plus stays undefined until resolved/error |

So changing the request resets all three with no reset code involved β€” and the gate on headers means you never read half-populated headers mid-flight.

The one place a reset is written by hand is override set(), which clears all three when you write a value locally. The signal graph covers the request path; the local-write path doesn't go through it.

One line inside that loader is the bridge between the signal world and the HTTP world:

Want to discuss this further?

Book a free strategy call with our team to see how these insights apply to your specific business goals.

Book a consultation