The Wasm Component Model on Fastly Compute
HTTP Cache: After the Send
September 8, 2026, by Kats Omuro (@katsuyukiomuro (opens in a new window) on X/Twitter)
Last time got as far as a suggested backend request, which you send yourself with send-uncached. A response comes back. You are holding an obligation, a response, and no obvious idea which of six functions discharges it.
Six is not an exaggeration, and choosing between them is most of the work in guest mode.
Ask what to do with it
Before deciding anything, hand the response back and ask:
/// Adjusts a response into the appropriate form for storage and provides a storage action
/// recommendation.
///
/// For example, if the looked-up request contains conditional headers, this function will
/// interpret a `304 Not Modified` response for revalidation by updating headers.
///
/// In addition to the updated response, this function returns the recommended storage action.
prepare-response-for-storage: func(
response: borrow<response>,
) -> result<tuple<storage-action, response>, error>;
Two things come back in that tuple, and both pieces are very important.
The response has been adjusted. If you sent a revalidation request and the origin answered 304 Not Modified, that 304 is not what belongs in the cache; the stored response with refreshed headers is. This function does that merge, which, as you could imagine, would be very annoying to implement by hand.
And the recommendation tells you which function to call next:
enum storage-action {
/// Insert the response into cache (for `transaction-insert` and
/// `transaction-insert-and-stream-back`).
insert,
/// Update the stale response in cache (for `transaction-update` and
/// `transaction-update-and-return-fresh`).
update,
/// Do not store this response.
do-not-store,
/// Do not store this response, and furthermore record its non-cacheability for other pending
/// requests (`transaction-record-not-cacheable`).
record-uncacheable,
}
This enum is unusual in a way that's easy to read past. Three of its four doc comments name the functions you should call in that case, in the WIT itself. It isn't describing a state, it's dispatching.
Which is a clue about how much the interface expects you to disagree. If following the recommendation were the only sensible path, the interface would just do it. Instead you get an enum whose cases are annotated with their corresponding calls, and the calls stay yours to make.
Six ways to discharge an obligation
insert and update each come in two flavors, plain and stream-back:
/// Inserts a response into the cache with the given options, returning a streaming body handle
/// that is ready for writing or appending.
///
/// Can only be used if the cache handle state includes the `must-insert-or-update` flag.
///
/// The response is consumed.
transaction-insert: func(
resp-handle: response,
options: write-options,
) -> result<body, error>;
/// Updates freshness lifetime, response headers, and caching settings without updating the
/// response body.
///
/// Can only be used in if the cache handle state includes both of the flags:
/// - `found`
/// - `must-insert-or-update`
///
/// The response is consumed.
transaction-update: func(
resp-handle: response,
options: write-options,
) -> result<_, error>;
You can see these have the same flag preconditions as the Core Cache's equivalents, same "can only be used in if" typo carried across, and the same reason update returns nothing: the body isn't changing, so there's nothing to stream. "The response is consumed" is new though. These take response by value, not borrow<response>, so the handle is gone afterwards.
Then the same two again, each returning a handle to read back through:
/// Inserts a response into the cache with the given options, and return a fresh cache handle
/// that can be used to retrieve and stream the response while it's being inserted.
///
/// This helps avoid the “slow reader” problem on a teed stream, for example when a program
/// wishes to store a backend request in the cache while simultaneously streaming to a client
/// in an HTTP response.
///
/// The response is consumed.
transaction-insert-and-stream-back: func(
resp-handle: response,
options: write-options,
) -> result<tuple<body, entry>, error>;
/// Updates freshness lifetime, response headers, and caching settings without updating the
/// response body, and return a fresh cache handle that can be used to retrieve and stream the
/// stored response.
///
/// Can only be used in if the cache handle state includes both of the flags:
/// - `found`
/// - `must-insert-or-update`
///
/// The response is consumed.
transaction-update-and-return-fresh: func(
resp-handle: response,
options: write-options,
) -> result<entry, error>;
Four names is more than it sounds like, because the differences between them are two independent questions rather than one list to memorize.
The first question is answered for you: prepare-response-for-storage hands back insert or update, and you follow it. The second is yours, and it is only ever whether you need to send this object downstream while you are storing it.
The return types fall out of that grid rather than having to be learned. The body is what you write the object into, so it appears exactly where there is an object to write. The entry is what you read back through, so it appears exactly where you said you also needed to serve it. An update changes headers and freshness rather than bytes, which is why its column never hands you a body at all, and why the plain form is the one function here that returns nothing.
The problem they solve is the teed-stream one the Core Cache's transactions worked through at length, and the mechanism is unchanged. Calling either stream-back form completes the request collapse, so everything queued behind you on this key wakes at that moment, each waiter holding a handle of its own onto the same half-written object. They read at the rate you write instead of waiting for you to finish.
Then there are two ways to give up.
First, transaction-choose-stale:
/// Fulfill an obligation to provide a response to the cache by selecting a stale-if-error response.
///
/// A guest that is obligated to insert/update the cache may not be able to produce an acceptable
/// response (e.g. unreachable backend, 5xx response). If the cache contains a response in the
/// stale-if-error period, the guest may prefer to use that response rather than returning an error.
/// If so, they can call transaction-choose-stale, after which the cache handle will reflect the stale
/// response (via get-found-response, get-state, etc).
///
/// `transaction-choose-stale` is an alternative to `transaction-update-and-return-fresh` or
/// `transaction-insert-and-stream-back`. Like those methods, it completes a request collapse,
/// providing the stale response to all collapsed transactions; and, after calling
/// `transaction-choose-stale`, the cache handle provides the (stale) response to send to the client.
///
/// However, `transaction-choose-stale` does not change the cached item. The next lookup will again
/// collapse and/or get an obligation to revalidate.
transaction-choose-stale: func() -> result<_, error>;
Your backend is down and there's a stale copy that's usable in stale-if-error. Serving stale beats serving a 502, and this action lets you do it: the obligation is discharged, everyone collapsed behind you gets the stale response, and the cached object is left exactly as it was so the next request tries again.
The other is transaction-record-not-cacheable:
/// Fulfill an obligation to provide a response to the cache by disabling request collapsing and
/// response caching for this cache entry.
///
/// In Varnish terms, this function stores a hit-for-pass object.
///
/// Only the max age and, optionally, the vary rule are read from the `options` argument.
transaction-record-not-cacheable: func(
options: write-options,
) -> result<_, error>;
If the response turned out to be uncacheable, it may be useful to be able to mark that object in the cache as so. Otherwise, every subsequent request would collapse, wait for a new leader, which would then discover the same uncacheability, and serialize behind each other for no benefit. A hit-for-pass object records "don't bother" for a while. Fastly documents that behavior, and what collapsing does when there is no such object, under request collapsing (opens in a new window).
You'll also see it says "Only the max age and, optionally, the vary rule are read from the options argument". That is the write-options record from the Core Cache posts showing its seams again: a record whose fields are conditionally meaningful depending on which function you hand it to, with the rule in prose.
Finally, if you can't take any of the six actions, you use transaction-abandon:
/// Abandons an obligation to provide a response to the cache.
///
/// Useful if there is an error before streaming is possible, for example if a backend is
/// unreachable.
///
/// If there are other requests collapsed on this transaction, one of those other requests will
/// be awoken and given the obligation to provide a response. If subsequent requests
/// are unlikely to yield cacheable responses, this may lead to undesired serialization of
/// requests. Consider using `transaction-record-not-cacheable` to make lookups for this request
/// bypass the cache.
transaction-abandon: func() -> result<_, error>;
Note that it recommends its own alternative. Abandoning wakes the next waiter, who will probably fail the same way, and the doc comment tells you so and points at the better call.
How to fill in the fields of write-options
write-options needs filling in, and the interface will suggest values:
/// Prepares a suggested set of cache write options for a given request and response pair.
///
/// The response is not consumed.
get-suggested-write-options: func(
response: borrow<response>,
) -> result<suggested-write-options, error>;
The suggested write options are not handed to you as a write-options record. Instead, it's a resource called suggested-write-options, and unusually for this file, it says why:
/// The methods in this resource return values that correspond to the fields in a
/// `write-options`. This type is used when a `write-options` value would
/// be returned, so that it can use `max-len` parameters when returning
/// dynamically-sized data, and so that it excludes the `extra` field, since borrowed
/// handles cannot be returned from functions.
resource suggested-write-options {
/// Returns the suggested value for the `write-options.max-age-ns` field.
get-max-age-ns: func() -> duration-ns;
/// Returns the suggested value for the `write-options.vary-rule` field.
get-vary-rule: func(max-len: u64) -> result<string, error>;
/// Returns the suggested value for the `write-options.initial-age-ns` field.
get-initial-age-ns: func() -> duration-ns;
/// Returns the suggested value for the `write-options.stale-while-revalidate-ns` field.
get-stale-while-revalidate-ns: func() -> duration-ns;
/// Returns the suggested value for the `write-options.stale-if-error-ns` field.
get-stale-if-error-ns: func() -> duration-ns;
/// Returns the suggested value for the `write-options.surrogate-keys` field.
get-surrogate-keys: func(max-len: u64) -> result<string, error>;
/// Returns the suggested value for the `write-options.length` field.
get-length: func() -> option<object-length>;
/// Returns the suggested value for the `write-options.sensitive-data` field.
get-sensitive-data: func() -> bool;
}
It has eight getters, one per field of the record you're trying to build, and every doc comment says the same sentence with a different field name in it.
You cannot pass this to transaction-insert. You have to call all eight, assemble a write-options yourself, and change whichever values you disagreed with along the way. The suggestion is a thing you interrogate, not a thing you edit.
One more oddity
Retrieving the stored response has a parameter worth stopping on:
/// Retrieves a stored response from the cache, returning `ok(none)` if
/// there was no response found.
///
/// If `transform-for-client` is set, the response will be adjusted according to the looked-up
/// request. For example, a response retrieved for a range request may be transformed into a
/// `206 Partial Content` response with an appropriate `content-range` header.
get-found-response: func(
transform-for-client: u32,
) -> result<option<response-with-body>, error>;
transform-for-client is a u32. Its doc comment describes it in purely boolean terms: "if transform-for-client is set". Even though WIT has a bool type, this is a flag typed as a 32-bit integer. We'll presume it's an artifact of an older hostcall signature for now, but reading compute.wit alone can't tell you which, and this series can't make proclamations about meanings in a WIT block.
The transformation it controls, though, is very useful. It turns a stored 200 into a 206 Partial Content with the right content-range for a range request: again, this is exactly the type of thing you don't want to hand-roll.
It is also the mirror of something the previous post noticed. get-suggested-backend-request strips a range on the way out so the cache stores whole objects; get-found-response puts one back on the way in. Host mode does both halves silently, and Fastly documents them together as automatic request transformations (opens in a new window). In guest mode each half is a parameter you pass.
Where the other hook goes
Put the whole phase in order and the second hook falls out of it, in the same place the first one did: between the suggestion and the action.
For example, the Rust SDK builds a "candidate response" (essentially the SDK's interface for suggested-write-options) as soon as the backend answers, and prepare-response-for-storage runs inside that constructor, so the recommended storage action is already attached to the candidate before your code sees it. The after-send hook runs next, against that candidate. Only then does the SDK finalize its options, taking the suggested storage action and the suggested write options and letting any override the hook set win over them.
That is the separation this interface was built around. Suggestions are computed early, actions happen late, and the hook is the gap between the two. A caller who wants the standard behavior attaches nothing and never learns any of these function names. A caller who wants the suggested max age but different surrogate keys overrides one field and leaves the rest alone.
The dispatch at the bottom is worth one more look. Six functions can discharge the obligation and this table reaches for four of them, always taking the stream-back flavor over the plain one. do-not-store is the interesting arm, because it calls no storage function at all—it calls transaction-abandon, and the SDK explains why: "to only wake a single waiter in the non-hit-for-pass case, so concurrent requests remain serialized." Abandoning is the give-up path everywhere else. Here it's chosen deliberately, to stop a herd forming behind a response nobody is going to cache.
Reading it directly
Full working code: full example on GitHub (opens in a new window).
None of this runs under Viceroy. Every function in http-cache returns Error::Unsupported there, as last time showed, so the example was deployed to a real Compute service instead. It runs the whole round trip—lookup, suggested request, send-uncached, prepare-response-for-storage, assemble write-options from all eight getters, then dispatch—and reports which storage action came back and which function discharged the obligation.
For the origin we resurface http-me (opens in a new window), because it can return whatever we ask it for. Since the example passes the incoming path straight through, the URL picks the origin's behavior and therefore the branch.
A cold miss against a cacheable response:
lookup state: LookupState(MUST_INSERT_OR_UPDATE)
origin status: Ok(200)
origin cache-control: max-age=60
storage-action: StorageAction::Insert
suggested max-age-ns: 60000000000
suggested swr-ns: Some(0)
suggested vary-rule: Some("")
discharged with: transaction-insert (5 bytes)
Sixty seconds of max-age arrives as 60000000000 nanoseconds, so get-suggested-write-options is reading the origin's cache directives and converting them, not guessing. Ask for the same URL again and there is no send at all:
lookup state: LookupState(FOUND | USABLE)
no obligation; serving what is stored
found status: Ok(200)
found body bytes: 5
The interesting case takes three steps: seed the cache with a response that stays available past its own max-age, wait for it to go stale, then revalidate it against an origin that answers 304.
Here's the log output:
origin status: Ok(304)
storage-action: StorageAction::Update
adjusted status: Ok(200)
discharged with: transaction-update
Read origin status against adjusted status. A 304 went in and a 200 came out. That is the merge described at the top of this post, the one worth the whole interface's existence, happening in a single call: prepare-response-for-storage took a bodiless 304, found the stored response it belonged to, applied the fresh headers, and handed back something storable. Nothing in the guest code knew which validator was used or which headers to copy.
The two give-up paths are reachable the same way. An origin that sends no-store:
origin cache-control: no-store,private
storage-action: StorageAction::RecordUncacheable
discharged with: transaction-record-not-cacheable
And an origin that fails outright:
origin status: Ok(500)
storage-action: StorageAction::DoNotStore
discharged with: transaction-abandon
That last pairing is the dispatch table's odd corner, now observed rather than read: do-not-store names no function of its own, so the guest picks one, and transaction-abandon is the pick here for the reason the SDK gave above.
One gap remains. transaction-choose-stale needs a stale entry and a failing backend on the same request, which needs an origin that turns hostile between two calls rather than one you address by URL. It stays unverified here.
Beyond the WIT
Count the decisions this interface asks a guest to make after a single backend response arrives: which storage action, out of four; which of six functions discharges the obligation; which of eight suggested values to accept and which to override; whether to transform for client; and whether the response is even cacheable, given that the built-in check told you POST isn't and that check is explicitly optional.
Any binding has to decide how much of that to answer on the caller's behalf, and there's a strong pull toward answering all of it. The full pipeline is long, most callers want the standard behavior, and a cache.fetch(request, backend) that follows every suggestion is both easy to write and easy to use.
The pull is worth resisting a little, because of a property this interface has that's easy to miss: the suggestions are separable from the actions. prepare-response-for-storage tells you what to do without doing it. get-suggested-write-options computes values without applying them. Every recommendation in this interface comes back as data, and every action is a separate call you make yourself.
That separation is the feature. It's what lets an application accept RFC 9111's answer about cacheability but override the vary rule, or take the suggested max age and add its own surrogate keys, without reimplementing the parts it agrees with. A wrapper that fuses recommendation and action back together throws away the one thing this design was built to give you, and gets it back only as a growing pile of override flags.
So we arrive at the same conclusion as last time, from the other side of the send. The honest shape is two layers rather than one: a high-level call for the common path, and the suggestion functions still public underneath it, so disagreeing with the default costs a caller a different function rather than a fork of your SDK. Landing there twice is worth noticing. The before-send phase reached it with one hook and four suggestions; this half has six discharge functions and a recommendation engine in front of them, and the answer didn't change. That's more surface area to document and maintain, which is a real cost, and it's the cost of not deciding on behalf of people who know their own traffic better than you do.
Next: serving a copy the cache has already called stale, which needs one of those six functions, two windows that look identical in the WIT, and a flag whose meaning depends on what it arrives with.