The Wasm Component Model on Fastly Compute
Calling a Backend by Hand
August 6, 2026, by Kats Omuro (@katsuyukiomuro (opens in a new window) on X/Twitter)
EDIT (2026-08-18): the quoted backend resource had its members out of source order and elided some without marking them; both fixed. Also added a comparison against the real Rust SDK's Request::send.
Quick aside before we get into it: today's my birthday, and thank you to everyone who sent me birthday wishes today—it meant a lot.
Last time we closed on a promise: in today's post we'd be making an outbound request of our own from our service, and everything we learned about bodies would carry straight over.
Surely building a request looks nothing like building a response—one's what a client sent you, one's what you send back, different shapes for different directions.
Well, as it turns out, they're the same shape. compute.wit just points it the other way.
Building a request
http-req's request resource opens exactly the way response did:
/// An HTTP request.
resource request {
/// Creates a new `request` with no method, URL, or headers, and an empty body.
new: static func() -> result<request, error>;
...
/// Gets the request method.
get-method: func(max-len: u64) -> result<string, error>;
/// Sets the request method.
set-method: func(method: string) -> result<_, error>;
/// Gets the request URI.
get-uri: func(max-len: u64) -> result<string, error>;
/// Sets the request URI.
set-uri: func(uri: string) -> result<_, error>;
...
}
It's the same static func() -> result<request, error> constructor, and the same instance methods taking an implicit self. request.new() gives you nothing—no method, no URL, no headers—for the same reason response.new() gave you nothing: the interface isn't going to guess what you want sent.
The only genuinely new pieces here are set-method and set-uri, and that's exactly what you'd expect: a response never needed either, since a response's "address" is just "back to whoever asked."
One phrase in that doc comment is worth pausing on: "an empty body." Look at the signature again—static func() -> result<request, error>. No body parameter, no body in the return type, nothing. request.new() can't attach a body, empty or otherwise, because a request was never wired to one in the first place. That's last post's whole point, still true from this side of the fence.
Finding a backend
Sending an outgoing request sounds simple enough. However, Fastly Compute requires backends (opens in a new window) (the servers you'll be making outgoing connections to) to be declared ahead of your code sending requests to them. Each backend needs to be configured with everything needed to actually open a connection: the IP address (or hostname), what port, what TLS version, how long to wait before giving up, whether to reuse a connection that's already open, and so on.
In other words, individual requests describe the actual messages, and a backend describes the connection that these requests travel over.
A backend is its own resource:
resource backend {
/// Attempts to open the named static backend.
open: static func(name: string) -> result<backend, open-error>;
/// Returns the name of this backend.
get-name: func() -> string;
...
/// Gets the host of this backend.
get-host: func(max-len: u64) -> result<string, error>;
...
/// Gets the remote TCP port of the backend connection for the request.
get-port: func() -> result<u16, error>;
/// Gets the connection timeout of the backend.
get-connect-timeout-ms: func() -> result<timeout-ms, error>;
...
/// Returns `true` if the backend is configured to use TLS.
is-tls: func() -> result<bool, error>;
...
}
A backend can be defined in two ways:
- A static backend - defined on your service (in your Fastly service configuration in production, or in
fastly.toml's[local_server.backends]in local testing) - A dynamic backend - defined at runtime in your code
backend.open(name) doesn't create anything—it looks up the name from the static backends already declared for your service. Dynamic backends are set up using a different mechanism, which we'll look at next time.
Sending it
Now that we have a backend, it's time to send that request. Let's say we're going to send it to https://http-me.fastly.dev/anything (opens in a new window), using the GET method. We set the following request-specific properties:
| Property | Value | Function Call |
|---|---|---|
| HTTP method | GET |
request.set-method("GET") |
| URI path | /anything |
request.set-uri("/anything") |
And since it's a GET request, we'll just use an empty body object.
What about the Host header? Actually, the Host header is optional if the backend has an override-host set, which is the recommended best practice.
When setting up a backend on a Fastly service, make sure you set the Override Host value.
When using the
[local_server.backends]configuration on the local testing server, ifoverride-hostis not provided, it is automatically set to the hostname of the backend's requiredurlvalue.
With a request set up, a new empty body, and a backend in hand, here's how to actually send the request:
/// Retrieves a response for the request, either from cache or by sending it
/// to the given backend server.
///
/// Returns once the response headers have been received, or an error occurs.
send: func(
request: request,
body: body,
backend: borrow<backend>,
) -> result<response-with-body, error-with-detail>;
request and body are still two separate handles, exactly like every function we've called so far. What's new is the return type:
type response-with-body = tuple<response, body>;
response-with-body isn't a new resource—it's just a plain tuple of the same two handle types we've been using this whole time. http-incoming.handle gave you a request and a body as two separate parameters; send-downstream took a response and a body as two separate parameters; send hands you a response and a body back together, bundled into one tuple. Same two pieces, just packaged differently depending on which direction they're moving.
send reaches for the cache first
Read that doc comment again: "either from cache or by sending it to the given backend server." send reads like a plain HTTP client call by another name. It isn't—it's the entry point to Fastly's readthrough HTTP cache (opens in a new window), and there's no separate opt-in. Every send call checks the cache first, and a cacheable response can get stored automatically going forward, based on ordinary HTTP caching semantics (Cache-Control and friends, per RFC 9111 (opens in a new window)) read off the backend's own response headers, with nothing for you to configure per call.
If you need to skip the cache, you can call this instead:
/// Sends the request directly to the backend server without performing any
/// caching or inserting any cache-related headers in the response.
///
/// Returns once the response headers have been received, or an error occurs.
send-uncached: func(
request: request,
body: body,
backend: borrow<backend>,
) -> result<response-with-body, error-with-detail>;
Same parameters, same return type as send—the only difference is the doc comment's promise: no caching, no cache-related headers added to the response, full stop.
Putting it together
mod bindings;
use bindings::{
exports::fastly::compute::http_incoming,
fastly::compute::{backend, http_body, http_req, http_resp},
};
struct CallingABackend;
impl http_incoming::Guest for CallingABackend {
fn handle(_request: http_incoming::Request, _request_body: http_body::Body) -> Result<(), ()> {
// Looks up a backend configured ahead of time — this can't
// point at an arbitrary host inline.
let backend = backend::Backend::open("http_me").map_err(|_| ())?;
let out_request = http_req::Request::new().map_err(|_| ())?;
out_request.set_method("GET").map_err(|_| ())?;
out_request.set_uri("/anything").map_err(|_| ())?;
let out_body = http_body::new().map_err(|_| ())?;
// response-with-body is just tuple<response, body>: the same two
// handles every other function passes separately.
let (_backend_response, backend_body) =
http_req::send(out_request, out_body, &backend).map_err(|_| ())?;
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();
let response = http_resp::Response::new().map_err(|_| ())?;
response
.insert_header("content-type", b"text/plain")
.map_err(|_| ())?;
let body = http_body::new().map_err(|_| ())?;
let msg = format!("Backend said:\n{backend_text}\n");
http_body::write(&body, msg.as_bytes()).map_err(|_| ())?;
http_resp::send_downstream(response, body).map_err(|_| ())?;
Ok(())
}
}
bindings::export!(CallingABackend with_types_in bindings);
That read loop (in the folded section) is word-for-word the one we wrote last time for the incoming request body. backend_body came back from a completely different function, going in a completely different direction, and it didn't need a single line changed. body doesn't care where it came from; we already knew that, and here it is paying off.
The backend itself is http-me.fastly.dev (opens in a new window), a small demo app built for exactly this kind of testing. Its /anything path echoes back a JSON description of whatever request hit it, which lets us see concretely that a real request went out and a real response came back, rather than trusting it silently.
We're not reaching for send-uncached in the example above. The plain send call still goes through the cache like any other—we're not doing anything to stop that. It just doesn't end up mattering here: http-me.fastly.dev's /anything responses don't send a Cache-Control header at all, so there's no freshness information for the cache to act on, and nothing actually stays cached. It's still worth knowing send-uncached exists for the day a backend's response headers say otherwise.
It's worth measuring all of that against what Fastly's Rust SDK asks for. The four steps above (open a backend, build a request, make an empty body, send it) come out as Request::get("/anything").send("http_me") (opens in a new window): one expression, with the backend named by string and the empty body implied. Backend::from_name (opens in a new window) is there when you want the handle itself, and its own documentation describes the thing in the same terms the WIT does, as "a backend associated with a service that we can send requests to, potentially caching the responses received."
The caching claim survives the trip intact. Request::send's documentation reads "Retrieve a response for the request, either from cache or by sending it to the backend," which is the WIT doc comment above with a few words changed. Two different ABIs, and the same sentence is still the accurate description of what happens.
Running it
Full working code: full example on GitHub (opens in a new window).
fastly.toml needs the backend declared before Viceroy will let you open it locally:
[local_server]
[local_server.backends.http_me]
url = "https://http-me.fastly.dev (opens in a new window)"
fastly compute serve
curl http://127.0.0.1:7676/ (opens in a new window)
Backend said:
{
"args": "",
"body": "",
"headers": {
"host": "http-me.fastly.dev"
},
"method": "GET",
"origin": "unknown",
"url": "/anything"
}
A real request left this component, hit a real backend over the real network, and the response came back through the exact same body-reading code we already had lying around.
Beyond the WIT
send isn't the only way to do this. send-async starts the request and hands you back a pending-response immediately, instead of blocking until the response headers arrive—useful for firing off several backend calls at once, or for a request whose response you don't even care about waiting for. send-async-streaming goes further, letting you keep writing into the request body after the request has already started sending. Both return the same pending-response—a pollable, exactly like body was—which is why we're not opening that door yet: waiting on, polling, and selecting between several of them at once is its own topic, worth doing properly rather than as an aside here.
One more detail worth naming: send's error type isn't the plain error we've seen everywhere else—it's error-with-detail. A local error.buffer-len failure is something you caused, by guessing a buffer size wrong; a failure calling a backend over a real network can happen for reasons entirely outside your code, and error-with-detail exists to say more about which one it was.
Next: backend.open only finds backends you've already declared ahead of time. Sometimes you don't know where you're sending a request until runtime, and compute.wit has a way to build one of those, too.
Here's to another year of coding!