The Wasm Component Model on Fastly Compute
Two Requests at Once, by Hand
August 10, 2026, by Kats Omuro (@katsuyukiomuro (opens in a new window) on X/Twitter)
EDIT (2026-08-18): marked the elisions in the quoted pollable, select-with-timeout, and send-async blocks, corrected a spliced doc-comment quotation in "Beyond the WIT," and added a comparison against the real Rust SDK's select.
Every backend call so far has looked the same: build a request, call send, wait for the response. One call, one wait, one result. Doing two of them, presumably, just means doing that twice in a row—call send, wait, call send again, wait again.
It does work. It's also strictly worse than it has to be: the second request doesn't even begin until the first one is completely done. If each one takes a second, two of them take two seconds, no matter how unrelated they are.
compute.wit has a way around that, and we already met half of it back in The Body Isn't Part of the Request (or the Response) without naming it: body is a pollable. Time to meet pollable properly, on its own terms.
pollable, in full
Here's the actual resource, from async-io:
/// An object supporting generic async operations.
///
/// Can be a `http-body.body`, `http-req.pending-response`, `http-req.pending-request`,
/// `cache.pending-entry`. `kv-store.pending-lookup`, `kv-store.pending-insert`,
/// `kv-store.pending-delete`, or `kv-store.pending-list`.
///
/// Each async item has an associated I/O action:
///
/// * Pending requests: awaiting the response headers / `response` object
/// * Normal bodies: reading bytes from the body
/// * Streaming bodies: writing bytes to the body
///
/// For writing bytes, there is a large buffer associated with the handle that bytes
/// can eagerly be written into, even before the origin itself consumes that data.
resource pollable {
/// ...
is-ready: func() -> bool;
/// ...
new-ready: static func() -> pollable;
}
That doc comment is doing real work: pollable isn't an HTTP-specific thing that body happens to reuse. It's the ABI's one generic "is this thing ready yet" primitive, and half the interfaces in compute.wit hand you one under a different local name: a body, a pending KV Store lookup, a pending cache entry, and the one we want today, a pending backend response.
is-ready is the cheap, nonblocking check: "would touching this thing right now block?" On its own, it's not that useful: if you had multiple things you're waiting on, you'd have to poll it in a spin loop.
What you actually want is a way to block until something in a whole set of pollables is ready, without caring which one first. That's select-with-timeout:
/// Blocks until one of the given objects is ready for I/O, or the timeout expires.
///
/// ...
///
/// The timeout is specified in milliseconds.
///
/// Returns the *index* (not handle!) of the first object that is ready, or `none` if the
/// timeout expires before any objects are ready for I/O.
select-with-timeout: func(handles: list<borrow<pollable>>, timeout-ms: u32) -> option<u32>;
This means you set up a list of pending work handles and hand it to select-with-timeout, and as the doc comment emphasizes, you get an index into the list you passed, not the handle itself. The wait also has a ceiling. If you miss it, you get none back—control returns to your code.
That last point matters more than it might look like. A Compute program runs on a single thread, and there's no background thread quietly handling your second request while the first one blocks. select-with-timeout is what makes that workable: instead of committing to "block until X is ready" for however long that takes, you commit to "block for at most N milliseconds," and when it comes back empty-handed you're free to do something else—check a deadline, service other pending work, whatever—before asking again.
If you've written JavaScript, this should feel familiar: a single thread, an event loop, and cooperative multitasking rather than true parallelism.
send-async and pending-response
send, which we've used since Calling a Backend by Hand, blocks until the response headers arrive. Its async counterpart doesn't:
/// Handle that can be used to wait for a response from a sent request.
use async-io.{pollable as pending-response};
...
/// Begins sending the request to the given backend server, and returns a
/// `pending-response` that can yield the backend response or an error.
///
/// This method returns as soon as the request begins sending to the backend,
/// and transmission of the request body and headers will continue in the
/// background.
///
/// This method allows for sending more than one request at once and receiving
/// their responses in arbitrary orders. See `pending-response` for more
/// details on how to wait on, poll, or select between pending responses.
///
/// ...
send-async: func(
request: request,
body: body,
backend: borrow<backend>
) -> result<pending-response, error>;
pending-response isn't a new resource—that use line is the same aliasing trick body used, just applied to pollable this time instead of async-io's own. It is a pollable, under a name that reads better at the call site. That's not just a documentation nicety, either—it holds all the way down into the generated Rust bindings, which we'll see in a moment.
send-async hands you that handle back almost immediately, before the response exists, and sending continues in the background. To actually get the response, you call this, once the handle is ready:
/// Waits until the request is completed, and then returns the resulting
/// response and body.
await-response: func(
pending: pending-response
) -> result<response-with-body, error-with-detail>;
Same response-with-body you already know from send. The difference is entirely in when you're allowed to ask for it—and that once you've asked, pending-response is consumed. await-response takes it by value, not a borrow.
The payoff
Two requests, one slow and one fast, sent one after another versus sent at once:
Sequential is bounded by the sum of both. Parallel is bounded by the slower one. That gap only grows as you add more requests.
Two requests, whichever finishes first
http-me.fastly.dev, the same test backend from Calling a Backend by Hand, has a wait=<ms> directive that sleeps before responding, which makes the timing difference easy to prove rather than just assert. Here's the whole thing:
mod bindings;
use bindings::{
exports::fastly::compute::http_incoming,
fastly::compute::{async_io, backend, http_body, http_req, http_resp},
};
use std::time::Instant;
struct TwoRequestsAtOnce;
impl http_incoming::Guest for TwoRequestsAtOnce {
fn handle(_request: http_incoming::Request, _request_body: http_body::Body) -> Result<(), ()> {
let backend = backend::Backend::open("http_me").map_err(|_| ())?;
let mut pending: Vec<(&str, http_req::PendingResponse)> = Vec::new();
for (label, wait_ms) in [("slow", 3000u32), ("fast", 1000u32)] {
let out_request = http_req::Request::new().map_err(|_| ())?;
out_request.set_method("GET").map_err(|_| ())?;
out_request
.set_uri(&format!("/anything?wait={wait_ms}"))
.map_err(|_| ())?;
let out_body = http_body::new().map_err(|_| ())?;
let p = http_req::send_async(out_request, out_body, &backend).map_err(|_| ())?;
pending.push((label, p));
}
let start = Instant::now();
let mut log = String::from("Sent 2 requests in parallel.\n");
while !pending.is_empty() {
let handles: Vec<&async_io::Pollable> = pending.iter().map(|(_, p)| p).collect();
let ready_index = match async_io::select_with_timeout(&handles, 500) {
Some(i) => i as usize,
None => {
log.push_str(&format!(
"[+{:.2}s] still waiting on {} request(s)\n",
start.elapsed().as_secs_f64(),
pending.len()
));
continue;
}
};
let (label, ready) = pending.remove(ready_index);
let (response, body) = http_req::await_response(ready).map_err(|_| ())?;
let status = response.get_status().map_err(|_| ())?;
let mut buf = Vec::new();
loop {
let chunk = http_body::read(&body, 8192).map_err(|_| ())?;
if chunk.is_empty() {
break;
}
buf.extend_from_slice(&chunk);
}
log.push_str(&format!(
"[+{:.2}s] \"{label}\" request finished: status {status}, {} bytes\n",
start.elapsed().as_secs_f64(),
buf.len()
));
}
log.push_str(&format!(
"\nTotal elapsed: {:.2}s\n",
start.elapsed().as_secs_f64()
));
let response = http_resp::Response::new().map_err(|_| ())?;
response
.insert_header("content-type", b"text/plain")
.map_err(|_| ())?;
let out_body = http_body::new().map_err(|_| ())?;
http_body::write(&out_body, log.as_bytes()).map_err(|_| ())?;
http_resp::send_downstream(response, out_body).map_err(|_| ())?;
Ok(())
}
}
bindings::export!(TwoRequestsAtOnce with_types_in bindings);
Notice the handles vector gets rebuilt fresh on every loop iteration, from whatever's left in pending. That's not incidental—it's the direct consequence of select-with-timeout returning a position, not an identity. Once one request finishes and pending.remove(ready_index) shifts everything after it down by one, the old indices are stale. If you ask for index 1 out of a list you didn't just rebuild, you're no longer asking about the request you think you are.
Also notice what doesn't need converting: pending is a Vec<(&str, http_req::PendingResponse)>, but select_with_timeout wants &[&async_io::Pollable]. No wrapper type, no .as_pollable() call—&p where p: &http_req::PendingResponse just is an &async_io::Pollable, because that's what the WIT alias promised.
Fastly's Rust SDK gets rid of the stale-index problem by never handing you an index. fastly::http::request::select (opens in a new window) takes a collection of PendingRequests and returns a tuple: "result is the result of the request that became ready," and "remaining is a vector containing all of the requests that did not become ready." You pass remaining straight into the next call, so there's no list to rebuild and no position to go stale. Its docs carry the same warning our loop needed, arriving from the other direction: the order of remaining "is not guaranteed to match the order of the requests in the argument collection."
There are two things it doesn't smooth over. The first is emptiness: "Panics if the argument collection is empty." The second is that select is the only selecting function in that module, with no timeout-taking variant anywhere, so the SDK exposes the shape of untimed select rather than select-with-timeout. Coming back for air is PendingRequest::poll()'s job there instead, checking one request without blocking rather than putting a ceiling on a wait over many.
Running it
Full working code: full example on GitHub (opens in a new window).
fastly compute serve
curl http://127.0.0.1:7676/ (opens in a new window)
Sent 2 requests in parallel.
[+0.50s] still waiting on 2 request(s)
[+1.00s] still waiting on 2 request(s)
[+1.04s] "fast" request finished: status 200, 156 bytes
[+1.54s] still waiting on 1 request(s)
[+2.04s] still waiting on 1 request(s)
[+2.54s] still waiting on 1 request(s)
[+3.05s] still waiting on 1 request(s)
[+3.25s] "slow" request finished: status 200, 156 bytes
Total elapsed: 3.25s
(Your own numbers will drift a little—this is wall-clock timing against a real network call, not a fixed simulation.) The "fast" request reports in around the 1-second mark, well before the "slow" one is anywhere near done, and the total stays close to 3 seconds rather than climbing to 4. The "still waiting" lines are select-with-timeout's none case firing every half-second, proving the loop really is coming back for air rather than sitting blocked on the slow request the whole time.
Beyond the WIT
The trap-on-empty-list behavior of select is the kind of thing you only find out about the hard way, once, and then never forget. It's not a bug, since the doc comment says it outright, but it's also not the kind of thing an error result would have made obvious. Contrast it with select-with-timeout, whose emptiness case (nothing ready before the deadline) is a normal option<u32> value, not a trap. These are two very similar functions holding two different philosophies about what counts as an exceptional condition versus routine flow, and that's worth internalizing before you write the loop rather than after it crashes. (select-with-timeout's own doc comment never actually says what it does with an empty list, for what it's worth: its none case is scoped to "the timeout expires," not "there was nothing to wait on." Treat that as unspecified rather than assuming either way.)
The judgment underneath it travels, too. Fastly's Rust SDK reaches this platform over a different ABI entirely, and its own select still answers an empty collection with a panic rather than a value. Two interfaces, built separately, both decided that waiting on nothing is a mistake in the caller rather than a situation to report back.
One more detail worth filing away for later, from the paragraph of send-async's doc comment elided above: the function is "also useful for sending requests where the response is unimportant, but the request may take longer than the Compute program is able to run, as the request will continue sending even after the program that initiated it exits." That's not just about parallelizing requests you're going to wait on. It's also the shape you'd reach for to fire off something you don't need to wait for at all, like a logging beacon. We're not building that today, but the pieces are already on the table.
Next time: this same pollable/select machinery isn't only for backend responses. The KV Store's pending-lookup, pending-insert, pending-delete, and pending-list all lean on it too, once we get there.