The Wasm Component Model on Fastly Compute
HTTP Cache: Before the Send
September 7, 2026, by Kats Omuro (@katsuyukiomuro (opens in a new window) on X/Twitter)
EDIT (2026-09-07): added links to Fastly's documentation on request collapsing (opens in a new window) and automatic request transformations (opens in a new window), which describe from the delivery side what transaction-lookup and the suggested request are doing here.
Last time drew the line between host mode, where send runs the readthrough cache for you off cache-override, and guest mode, where you drive http-cache yourself. Running that pipeline splits in half at the moment you actually talk to the backend, and the two halves already have names—just not in compute.wit.
The SDKs call them the before-send and after-send phases, and each is exposed as a hook you can hang a function on. compute.wit names neither, and offers no hook at all. What it has instead is a set of functions that happen to divide cleanly on either side of send-uncached, which is what the SDKs noticed before they built anything.
This post is the top half of that picture.
Should this even be cached?
The interface opens with a question you can ask before starting anything:
/// Determines whether a request is cacheable per conservative [RFC 9111] semantics.
///
/// In particular, this function checks whether the request method is `GET` or `HEAD`, and
/// considers requests with other methods uncacheable. Applications where it is safe to cache
/// responses to other methods should consider using their own cacheability check instead of
/// this function.
///
/// [RFC 9111]: https://www.rfc-editor.org/rfc/rfc9111.html (opens in a new window)
is-request-cacheable: func(request: borrow<request>) -> result<bool, error>;
It's a free function rather than a method on anything, it takes a borrowed request, and it returns a plain bool. There's no handle to acquire, no transaction to open, and nothing is changed by asking. You can ask it about a request you have no intention of caching.
Read that doc comment twice, because it does something unusual: it tells you when not to call it. "Conservative" is doing real work in the first line, and the rest spells out what the conservatism costs. The check is GET or HEAD and nothing else, and an application that knows caching a POST response is safe "should consider using their own cacheability check instead of this function."
That's the same suggestion framing the map post quoted for the whole family, now aimed at the narrowest possible target: one bool, and the doc comment still won't insist you believe it.
Then there's the key:
/// Retrieves the default cache key for the request.
///
/// If the full key requires more than `max-len` bytes, an `error.buffer-len`
/// error is returned containing the required size.
///
/// At the moment, HTTP cache keys must always be 32 bytes.
get-suggested-cache-key: func(
request: borrow<request>,
max-len: u64,
) -> result<list<u8>, error>;
"At the moment, HTTP cache keys must always be 32 bytes" is a constraint the Core Cache never had. There, a key was list<u8> of any length you liked. Here it's fixed-width, which reads like a hash of the request, and the max-len growable-buffer dance is preserved anyway despite the size being known and stated in the same doc comment.
Starting the transaction
/// An HTTP Cache transaction.
resource entry {
/// Performs a cache lookup based on the given request.
///
/// This operation always participates in request collapsing and may return an obligation to
/// insert or update responses, and/or stale responses.
///
/// The request is not consumed.
transaction-lookup: static func(
req-handle: borrow<request>,
options: lookup-options,
) -> result<entry, error>;
...
}
Two differences from the Core Cache's version are worth putting side by side. The key is a whole request rather than a byte string, since the cache derives the key and the vary matching from the request itself. And there is no non-transactional alternative: entry.lookup exists in cache and has no counterpart here.
That last point contradicts this interface's own documentation. The http-cache doc comment, quoted verbatim in the map post, says "The starting points for this API are lookup (no request collapsing) and transaction-lookup (request collapsing)." There is no lookup function in http-cache. Reading the interface end to end turns up transaction-lookup and nothing else, so the doc comment is describing an entry point the interface doesn't have—either removed at some point without the prose being updated, or copied across from cache, where both do exist. Which leaves collapsing as something you cannot opt out of here: every lookup joins one, and Fastly describes what that means for the requests waiting behind yours under request collapsing (opens in a new window).
The lookup options differ too:
/// Non-required options for cache lookups.
record lookup-options {
/// Cache key to use in lieu of the automatically-generated cache key based on the request's
/// properties.
///
/// The cache key must be exactly 32 bytes long.
override-key: option<list<u8>>,
/// Backend that will be used for the eventual request.
backend: option<borrow<backend>>,
...
}
override-key is how you escape the suggested key when the request-derived one groups things you'd rather keep apart, or separates things you'd rather share. The 32-byte rule shows up here too, stated as a hard requirement rather than a current limitation, so a key you invent has to be a fixed-width digest of something rather than anything readable.
backend is stranger. A lookup takes the backend "that will be used for the eventual request"—a piece of information about a fetch that hasn't happened and might never happen, since the whole point of the lookup is to find out whether you need one. Nothing in the WIT says why the cache would want it.
The Rust SDK never leaves it out. Its internal begin_lookup takes a backend as a required parameter and passes backend: Some(backend.as_handle()) on every guest-mode lookup, and its error type has an InvalidBackend variant documented as "Cache operation indicated, on miss, an invalid backend would be used." Read together those suggest a field you had better fill in.
They don't quite mean that. Running the same lookup twice against a real service, once with backend: None and once with a real backend handle, produces identical results—same state, same suggested request, no error either way. The output is further down. So the option is a real option, the SDK passes it because it always has a backend in hand rather than because the call needs one, and InvalidBackend is about naming a backend that isn't valid rather than about naming none.
Which leaves the field's actual purpose where the WIT left it. Something downstream of a lookup presumably wants to know where the fetch is headed, and neither the type nor the doc comment says what.
What should I ask the backend?
The lookup gives you an obligation, the same must-insert-or-update flag as the Core Cache. Now you have to go and get the thing. The interface offers to write the request for you:
/// Prepares a suggested request to make to a backend to satisfy the looked-up request.
///
/// If there is a stored, stale response, this suggested request may be for revalidation. If the
/// looked-up request is ranged, the suggested request will be unranged in order to try caching
/// the entire response.
get-suggested-backend-request: func() -> result<request, error>;
This is the function that earns the interface its keep, and the doc comment names two of the fiddliest parts of RFC 9111 caching almost in passing.
If there's a stale response sitting in the cache, the suggested request is a revalidation request. That means conditional headers, If-None-Match from the stored ETag or If-Modified-Since from the stored Last-Modified, so the origin can answer 304 Not Modified and save you the body. Getting that right by hand means reading stored response headers, knowing which validators to prefer, and formatting them correctly.
And if the client asked for a byte range, the suggested request drops the range. That's not obvious and it's clearly correct: caching bytes 500-999 of an object gives you an entry that satisfies almost no future request, whereas fetching the whole object once serves every range afterwards. Host mode does this for you, along with the reverse transformation on the way back out, and Fastly documents the pair as automatic request transformations (opens in a new window).
Both of those are decisions, not translations. The suggested request is the interface volunteering an opinion about the right way to satisfy this lookup, and returning it as a request resource you can modify or discard.
Where the hook goes
Four calls, and the phase is over. Put them in order and the shape of every SDK's before-send hook falls out of it.
The Rust SDK's internal state machine runs exactly this sequence, and the doc comment on it is more precise than a paragraph of mine would be: "The cache transaction provides the suggested backend request. We run before_send against that request, capture the final cache override, and then begin the uncached origin fetch."
Read the order. It's get-suggested-backend-request first, your hook second, send-uncached third. The hook doesn't receive the request the client sent you. It receives the one the cache suggested—already unranged, already carrying revalidation headers if there was a stale entry to revalidate—and whatever you do to it there is the last edit before the bytes leave.
That ordering is why a before-send hook is worth having at all. A hook that fired on the original request would be a request-rewriting callback, and you can already write one of those without a cache anywhere in the picture. Firing it here puts you downstream of every decision this post has walked through, holding a request that four functions have already had opinions about, with a chance to disagree with any of them.
Reading it directly
Full working code: full example on GitHub (opens in a new window).
The example runs every call in this post, and runs the lookup twice so the backend question above gets an answer.
mod bindings;
use bindings::{
exports::fastly::compute::http_incoming,
fastly::compute::{backend, http_body, http_cache, http_resp},
};
struct HttpCacheBeforeTheSend;
fn probe(lines: &mut String, label: &str, options: &http_cache::LookupOptions, req: &http_cache::Request) {
lines.push_str(&format!("\n--- transaction-lookup with {label} ---\n"));
match http_cache::Entry::transaction_lookup(req, options) {
Ok(entry) => {
lines.push_str("transaction-lookup: Ok(entry)\n");
lines.push_str(&format!("state: {:?}\n", entry.get_state()));
match entry.get_suggested_backend_request() {
Ok(r) => {
lines.push_str(&format!("suggested method: {:?}\n", r.get_method(64)));
lines.push_str(&format!("suggested uri: {:?}\n", r.get_uri(256)));
}
Err(e) => lines.push_str(&format!("suggested request: Err({e:?})\n")),
}
let _ = http_cache::close_entry(entry);
}
Err(e) => lines.push_str(&format!("transaction-lookup: Err({e:?})\n")),
}
}
impl http_incoming::Guest for HttpCacheBeforeTheSend {
fn handle(request: http_incoming::Request, _request_body: http_body::Body) -> Result<(), ()> {
let mut lines = String::new();
lines.push_str(&format!("is-request-cacheable: {:?}\n", http_cache::is_request_cacheable(&request)));
lines.push_str(&format!("suggested-cache-key: {:?}\n", http_cache::get_suggested_cache_key(&request, 64)));
let opened = backend::Backend::open("http_me");
lines.push_str(&format!("backend open: {}\n", match &opened {
Ok(b) => format!("Ok({:?})", b.get_name()),
Err(e) => format!("Err({e:?})"),
}));
probe(&mut lines, "backend: None", &http_cache::LookupOptions {
override_key: None, backend: None, extra: None,
}, &request);
match &opened {
Ok(b) => probe(&mut lines, "backend: Some(http_me)", &http_cache::LookupOptions {
override_key: None, backend: Some(b), extra: None,
}, &request),
Err(_) => lines.push_str("\n--- transaction-lookup with backend: Some(..) skipped, backend did not open ---\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, lines.as_bytes()).map_err(|_| ())?;
http_resp::send_downstream(response, out_body).map_err(|_| ())?;
Ok(())
}
}
bindings::export!(HttpCacheBeforeTheSend with_types_in bindings);
Against Viceroy, requesting /products/42:
is-request-cacheable: Err(Error::Unsupported)
suggested-cache-key: Err(Error::Unsupported)
backend open: Ok("http_me")
--- transaction-lookup with backend: None ---
transaction-lookup: Err(Error::Unsupported)
--- transaction-lookup with backend: Some(http_me) ---
transaction-lookup: Err(Error::Unsupported)
Every function in the interface, including the two that take nothing but a borrowed request and have no business needing cache storage to answer. The backend opens fine, so it isn't the plumbing.
This is a bigger gap than the ones this arc has hit so far. Those were individual functions and one sub-API: get-hits and get-stale-while-revalidate-ns are getters, purge-surrogate-key-verbose is one form of one call, and replace is the door to everything behind it. Here the entire interface is absent locally, which means the guest cache pipeline can't be exercised on a laptop at all. Core Cache work is testable locally; guest-mode HTTP caching, today, is not.
So this one got deployed to a real Compute service instead. Same code, same request:
is-request-cacheable: Ok(true)
suggested-cache-key: Ok([163, 16, 80, 81, 246, 159, 42, 101, 104, 174, 4, 178,
132, 239, 148, 130, 126, 7, 108, 233, 91, 85, 154, 36,
39, 154, 214, 3, 128, 246, 188, 63])
backend open: Ok("http_me")
--- transaction-lookup with backend: None ---
transaction-lookup: Ok(entry)
state: Ok(LookupState(MUST_INSERT_OR_UPDATE))
suggested method: Ok("GET")
suggested uri: Ok("https://<the (opens in a new window) service domain>/products/42")
--- transaction-lookup with backend: Some(http_me) ---
transaction-lookup: Ok(entry)
state: Ok(LookupState(MUST_INSERT_OR_UPDATE))
suggested method: Ok("GET")
suggested uri: Ok("https://<the (opens in a new window) service domain>/products/42")
Count the key: thirty-two bytes, exactly as the doc comment said, and opaque enough that it's clearly a digest. The cold lookup hands back must-insert-or-update, which is the obligation this whole post has been describing. The suggested request is a plain GET at the original URI, because there's nothing stale to revalidate and no range to drop.
And the two lookups are identical. Passing a backend and passing nothing produce the same state, the same suggested request, and no error either way, which is the answer to the question the lookup-options section left open.
Now change the method:
is-request-cacheable: Ok(false)
--- transaction-lookup with backend: None ---
transaction-lookup: Ok(entry)
is-request-cacheable says no. transaction-lookup opens a transaction for that POST anyway, in the very next call, without complaint.
Beyond the WIT
Look at what this interface is willing to do for you, and then at what it calls it.
get-suggested-backend-request will build a conditional revalidation request from a stored response's validators. is-request-cacheable implements RFC 9111's cacheability rules. get-suggested-cache-key derives a key from a request. Each of those is real work, correctly done, and each is offered as a suggestion the interface expects you might decline.
That's an unusual stance for an ABI, and the POST output above is it in two lines. is-request-cacheable answers false, and the very next call opens a cache transaction on that same request without a word of protest. Most of compute.wit is mechanism: insert inserts, lookup looks up, and the host has no opinion about whether you should have called it. This interface has opinions, encodes a specification's worth of them, and then declines to enforce a single one.
The reason is in the doc comment the map post quoted: applications sometimes know better. Caching a POST response can be perfectly safe when you own both ends. A cache key that ignores a header RFC 9111 says is significant may be exactly right for your service. An ABI that enforced the RFC would make those cases impossible; one that ignored it would make the common case laborious.
For a binding, that shape is a trap worth naming. The obvious idiomatic move is to hide the suggestions behind a helpful default, cache.fetch(request) that calls all three and does the sensible thing, because that's what most callers want most of the time. Do that and you've quietly converted advice into policy, and the escape hatch the ABI deliberately built has to be reintroduced as options on your wrapper, one flag at a time, as users discover the cases you decided for them.
The alternative is to keep the suggestion visible in the API's shape, so that "ask what to do" and "do it" stay separate calls the way they are in the WIT. That is a worse first-use experience and an honest one.
Somewhere in between sits the option the SDKs actually took, and it's the before-send hook. The shortcut runs, all four suggestions get applied, and the hook is a seam cut into the middle of it where a caller who disagrees is handed the suggested request before it goes anywhere. That beats a flag for every decision, because it doesn't require guessing in advance which of them somebody is going to want to overturn.
Next: the send happens, a response comes back, and we pick up in the bottom half.