The Wasm Component Model on Fastly Compute
Streaming a Request, by Hand
August 11, 2026, by Kats Omuro (@katsuyukiomuro (opens in a new window) on X/Twitter)
EDIT (2026-08-18): added a comparison against the real Rust SDK's send_async_streaming and StreamingBody.
Every backend call we've made so far builds the whole body first, then hands it over. send takes it. send-async, from last time, takes it too—the "async" part is only about the response side, not the request you're sending. That's about as async as it gets, right? Fire, then wait. It isn't.
A body that doesn't leave your hands
/// Begins sending the request to the given backend server, and returns a
/// `pending-response` that can yield the backend response or an error.
///
/// The `body` argument is not consumed, so that it can accept further data to send.
///
/// The backend connection is only closed once `http-body.close` is called. The
/// `pending-response` will not yield a `response` until the body is finished.
///
/// This method is most useful for programs that do some sort of processing or
/// inspection of a potentially-large client request body. Streaming allows the
/// program to operate on small parts of the body rather than having to read it all
/// into memory at once.
///
/// 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.
send-async-streaming: func(
request: request,
body: borrow<body>,
backend: borrow<backend>,
) -> result<pending-response, error>;
Look at the parameter list next to send-async's: same request, same backend, but body: body has become body: borrow<body>. That borrow<T> shape is the one we already met on read/write—the handle you pass in isn't consumed, and you still have it once the call returns. Every send-shaped function before this one has taken body by value: hand it over, it's gone. send-async-streaming is the first one that hands it back.
That's not a small detail. It's the whole reason this function exists: you keep the body, which means you can keep writing to it after you've already started sending.
What "not consumed" buys you
The doc comment is really making one claim from three angles. The body argument isn't consumed, so you can call http-body.write on it again after this function returns. The backend connection only closes once http-body.close is called, so the platform doesn't guess you're done; it waits to be told. And the pending-response won't yield a response until the body is finished, because the backend can't send a reply to a request it hasn't fully received.
Put together: you get a pending-response back almost immediately, same as send-async, but this time it's not a promise for a request that's already fully queued up—it's a promise for a request that's still, actively, being written by your code. The doc comment names exactly the case this is for: a "potentially-large client request body," operated on in "small parts... rather than having to read it all into memory at once."
There's an uncached sibling too, documented the same way every uncached variant in this interface is—relative to its base rather than repeating the whole explanation:
/// This is to `send-async-streaming` as `send-uncached` is to `send`.
///
/// As with `send-uncached`, this function sends the request directly to the
/// backend server without performing any caching or inserting any
/// cache-related headers in the response.
send-async-uncached-streaming: func(
request: request,
body: borrow<body>,
backend: borrow<backend>,
) -> result<pending-response, error>;
Same relationship, same shape, just skipping cache involvement—nothing about streaming changes.
Fastly's Rust SDK reaches the same capability by inverting who makes the body. Request::send_async_streaming(backend) (opens in a new window) returns Result<(StreamingBody, PendingRequest), SendError>, so you don't build a body and lend it out. You start the request and get the write end back. StreamingBody implements Write, which turns the forwarding loop we're about to write into ordinary Rust I/O, and its finish() is where http-body.close ends up.
Both designs are answering the same ownership question, and neither gets to dodge it. The WIT version has to say borrow<body> out loud, because a body handle belongs to the host and the call would otherwise consume it. The SDK version never has to, because what it hands back is a guest-side value nothing else can be holding. The obligation to end the stream on purpose survives either way, as close or as finish(), and skipping it leaves the other end looking at a transfer that died halfway.
Forwarding a body without buffering it
Here's the motivating case, built by hand: read the incoming request body a piece at a time, and forward each piece to a backend as it arrives, rather than reading the whole thing into a Vec first and sending it in one shot.
mod bindings;
use bindings::{
exports::fastly::compute::http_incoming,
fastly::compute::{backend, http_body, http_req, http_resp},
};
use std::thread::sleep;
use std::time::{Duration, Instant};
struct StreamingARequest;
impl http_incoming::Guest for StreamingARequest {
fn handle(_request: http_incoming::Request, request_body: http_body::Body) -> Result<(), ()> {
let backend = backend::Backend::open("http_me").map_err(|_| ())?;
let out_request = http_req::Request::new().map_err(|_| ())?;
out_request.set_method("POST").map_err(|_| ())?;
out_request.set_uri("/anything").map_err(|_| ())?;
let out_body = http_body::new().map_err(|_| ())?;
let start = Instant::now();
let mut log = String::new();
// out_body is still empty here — the request begins sending anyway.
let pending =
http_req::send_async_streaming(out_request, &out_body, &backend).map_err(|_| ())?;
log.push_str(&format!(
"[+{:.2}s] send-async-streaming returned a pending-response; out_body has nothing written yet\n",
start.elapsed().as_secs_f64()
));
// Read the incoming body in small pieces and forward each one to
// out_body as soon as it shows up, instead of buffering it all first.
// The sleep stands in for a client trickling a large upload in slowly.
loop {
let chunk = http_body::read(&request_body, 8).map_err(|_| ())?;
if chunk.is_empty() {
break;
}
http_body::write(&out_body, &chunk).map_err(|_| ())?;
sleep(Duration::from_millis(250));
log.push_str(&format!(
"[+{:.2}s] forwarded {} byte(s): {:?}\n",
start.elapsed().as_secs_f64(),
chunk.len(),
String::from_utf8_lossy(&chunk)
));
}
// A successful stream termination, not just dropping the handle.
http_body::close(out_body).map_err(|_| ())?;
log.push_str(&format!(
"[+{:.2}s] closed out_body\n",
start.elapsed().as_secs_f64()
));
let (backend_response, backend_body) =
http_req::await_response(pending).map_err(|_| ())?;
let status = backend_response.get_status().map_err(|_| ())?;
log.push_str(&format!(
"[+{:.2}s] await-response returned: status {status}\n",
start.elapsed().as_secs_f64()
));
let mut buf = Vec::new();
loop {
let chunk = http_body::read(&backend_body, 8192).map_err(|_| ())?;
if chunk.is_empty() {
break;
}
buf.extend_from_slice(&chunk);
}
let backend_text = String::from_utf8_lossy(&buf).into_owned();
log.push_str(&format!("\nBackend said:\n{backend_text}\n"));
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!(StreamingARequest with_types_in bindings);
The chunk-size in that read loop is deliberately tiny—8 bytes—so a short test string still produces several visible steps instead of arriving in one read. In a real upload-processing service, the chunk size would be picked for throughput, not for demo legibility, but the mechanism is exactly the same either way: send_async_streaming doesn't care how many times you call write on the body it borrowed, or how long you take between calls.
Running it
Full working code: full example on GitHub (opens in a new window).
fastly compute serve
curl -X POST -d "hello from the request body, streamed in pieces" http://127.0.0.1:7676/ (opens in a new window)
[+0.00s] send-async-streaming returned a pending-response; out_body has nothing written yet
[+0.25s] forwarded 8 byte(s): "hello fr"
[+0.50s] forwarded 8 byte(s): "om the r"
[+0.75s] forwarded 8 byte(s): "equest b"
[+1.01s] forwarded 8 byte(s): "ody, str"
[+1.26s] forwarded 8 byte(s): "eamed in"
[+1.51s] forwarded 7 byte(s): " pieces"
[+1.51s] closed out_body
[+1.52s] await-response returned: status 200
Backend said:
{
"args": "",
"body": "hello from the request body, streamed in pieces",
"headers": {
"host": "http-me.fastly.dev",
"transfer-encoding": "chunked"
},
"method": "POST",
"origin": "unknown",
"url": "/anything"
}
send-async-streaming hands back its pending-response at +0.00s, before a single byte of out_body exists—exactly what "not consumed" and "returns as soon as the request begins sending" promised. await-response doesn't resolve until +1.52s, right after close, not a moment before—exactly what "won't yield a response until the body is finished" promised. And http-me reports back "transfer-encoding": "chunked": the backend genuinely received this as a stream, not as one request with a known Content-Length assembled behind the scenes.
Beyond the WIT
Go back to async-io.pollable's own doc comment, quoted in full last time: "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."
Every use of is-ready we've seen up to now has been on something you read—a request body, a pending backend response—where "ready" means "there's something new to consume." out_body here is a write-direction pollable, and for it, "ready" means something opposite: "there's still room in the buffer to accept more." So yes, it's the same method name on the same resource type, but it answers a meaningfully different question, depending on which direction the pipe runs. We didn't call is-ready on out_body in the code above, since write will simply write fewer bytes than requested if the buffer's full and polling isn't required. It's there if you want to avoid a write call that comes back having written less than you asked for.
And one more thing worth sitting with. out_body here is a body and pending is a pending-response, two different local names from two different interfaces, but both are underneath the exact same async-io.pollable resource. Nothing in select/select-with-timeout's signature cares what a pollable is for. A list mixing a body you're still writing to and a response you're still waiting on is just as valid a thing to select over as the same-typed list we built last time. We haven't needed that yet, since every example so far has waited on one kind of thing at a time, but it's sitting right there in the type system if we need it.
Next: we leave HTTP and concurrency behind for a while and open the Data Stores—starting with Config Store, compute.wit's key/value interface for configuration that isn't a secret.