The Wasm Component Model on Fastly Compute
Image Optimizer: Hand Over the Request, Get Back an Image
September 25, 2026, by Kats Omuro (@katsuyukiomuro (opens in a new window) on X/Twitter)
EDIT (2026-09-25): added an opening on what Image Optimizer is, corrected what the Rust SDK's SendError actually restores, and pointed the parameter list at Fastly's Image Optimizer reference.
Fastly Image Optimizer (opens in a new window) is, in Fastly's description, "an image optimization service that manipulates and transforms your images in real time and caches optimized versions of them." You keep one original on your origin at full size, and ask for the width, crop, and format you actually want per request. The work happens at the edge, and the result is cached, so the next request for the same treatment doesn't repeat it.
compute.wit reaches it through one interface with two functions, and it's the first interface in this series you can't exercise at all without someone at Fastly turning it on for you. Viceroy doesn't pretend to implement it, and a real service can't use it until it has been enrolled. So this post is mostly about what the ABI asks of you before any image gets resized, and what it takes away when the call fails.
Two functions, one of them retired
interface image-optimizer {
use http-body.{body};
use http-req.{request};
use http-resp.{response-with-body};
use types.{error};
use backend.{backend};
record image-optimizer-transform-options {
/// Contains any Image Optimizer API parameters that were set
/// as well as the Image Optimizer region the request is meant for.
sdk-claims-opts: option<string>,
/// Additional options may be added in the future via this resource type.
extra: option<borrow<extra-image-optimizer-transform-options>>,
}
/// Extensibility for `image-optimizer-transform-options`
resource extra-image-optimizer-transform-options {}
/// Send a request to Image Optimizer.
send-to-image-optimizer: func(
origin-image-request: request,
origin-image-request-body: option<body>,
origin-image-request-backend: borrow<backend>,
io-transform-options: image-optimizer-transform-options,
) -> result<response-with-body, error>;
/// No longer supported; use `send-image-optimizer-request` instead.
@since(version = 0.0.0)
@deprecated(version = 0.1.0)
transform-image-optimizer-request: func(
origin-image-request: borrow<request>,
origin-image-request-body: option<body>,
origin-image-request-backend: borrow<backend>,
io-transform-options: image-optimizer-transform-options,
) -> result<response-with-body, error>;
}
send-to-image-optimizer looks like send with an extra argument. You give it a request for the original image, the backend that serves it, and some options. Image Optimizer fetches the image, transforms it, and you get back an ordinary response and body.
The second function carries two annotations. The environment post met @since on WASI's interfaces. This is the only place in compute.wit carrying either annotation, marking a function that existed before this package's 0.1.0 and was retired at it. Its doc comment says to use send-image-optimizer-request instead, though if we look carefully, we see there's no function by that name. They meant send-to-image-optimizer here, and it's probably the same kind of drift log showed, where a doc comment names a function the interface doesn't have.
The more important difference between the two, though, is something else: the retired function borrows the request, with borrow<request>. In contrast, the replacement takes it (moves it): request.
A string you have to know how to write
All of Image Optimizer's configuration goes through one field. sdk-claims-opts is an option<string> that "contains any Image Optimizer API parameters that were set as well as the Image Optimizer region the request is meant for." What the string should look like isn't stated in the WIT.
Where you need to look is the Image Optimizer reference (opens in a new window), which lists the API parameters Fastly documents for the URL-based interface: width, height, format, crop, quality, dpr, and a couple of dozen more, each with its own page giving the values it takes.
Only the delivery differs: on a CDN service you put them in the URL's query string; here you put the same names and values into sdk-claims-opts, urlencoded in the same name=value&name=value form.
The WIT comment also mentions region separately from the parameters, as "the Image Optimizer region the request is meant for," but it doesn't go into the accepted values.
Digging into the Rust SDK (opens in a new window) I found that it's one of us_east, us_central, us_west, eu_central, eu_west, asia, or australia. It also turns out that SDK won't build the string without one, so treat it as required.
The region has a second job. The SDK's documentation for set_image_optimizer says there's no need to add shielding logic for Image Optimizer, because the required region "handles this for you," and recommends choosing one close to your origin. Shielding is part of how Image Optimizer works, and the region is where you configure it.
Sending, and falling back
Full working code: full example on GitHub (opens in a new window).
The example asks for http-me.fastly.dev's sample JPEG, resized to 200 pixels wide and converted to WebP. If Image Optimizer refuses, it serves the original instead, and a header says which happened.
mod bindings;
use bindings::{
exports::fastly::compute::http_incoming,
fastly::compute::{backend, http_body, http_req, http_resp, image_optimizer},
};
const IMAGE_URL: &str = "https://http-me.fastly.dev/image-jpeg";
fn origin_request() -> Result<http_req::Request, ()> {
let request = http_req::Request::new().map_err(|_| ())?;
request.set_uri(IMAGE_URL).map_err(|_| ())?;
Ok(request)
}
struct ImageOptimizer;
impl http_incoming::Guest for ImageOptimizer {
fn handle(_request: http_incoming::Request, _request_body: http_body::Body) -> Result<(), ()> {
let origin = backend::Backend::open("origin").map_err(|_| ())?;
// Image Optimizer API parameters, including the region, as a query string.
let options = image_optimizer::ImageOptimizerTransformOptions {
sdk_claims_opts: Some("region=us_east&width=200&format=webp".to_string()),
extra: None,
};
let result = image_optimizer::send_to_image_optimizer(origin_request()?, None, &origin, &options);
let (response, body, note) = match result {
Ok((response, body)) => (response, body, "transformed".to_string()),
Err(e) => {
// The request was moved into the call and didn't come back. Build it again.
let (response, body) = http_req::send(origin_request()?, http_body::new().map_err(|_| ())?, &origin)
.map_err(|_| ())?;
(response, body, format!("untransformed, send-to-image-optimizer: Err({e:?})"))
}
};
response.insert_header("x-image-optimizer", note.as_bytes()).map_err(|_| ())?;
http_resp::send_downstream(response, body).map_err(|_| ())
}
}
bindings::export!(ImageOptimizer with_types_in bindings);
Look at the Err arm. The request went into send-to-image-optimizer by value, so after the call there's nothing left to send to the origin, successful or not. The fallback has to build the request again from scratch, which is why building it lives in a function of its own.
In production, the origin backend is an ordinary static backend in [setup]:
[setup]
[setup.backends]
[setup.backends.origin]
address = "http-me.fastly.dev"
description = "http-me test origin, which serves a sample JPEG at /image-jpeg"
port = 443
And for local testing, the same backend under [local_server]:
[local_server]
[local_server.backends]
[local_server.backends.origin]
url = "https://http-me.fastly.dev"
Neither says anything about Image Optimizer, because neither can turn it on. The section below covers what does.
What Viceroy says
Run fastly compute serve and request anything:
curl -s -D - -o image.jpg http://127.0.0.1:7676/
HTTP/1.1 200
connection: keep-alive
x-image-optimizer: untransformed, send-to-image-optimizer: Err(Error::Unsupported)
content-type: image/jpeg
x-served-by: cache-itm1220074-ITM
date: Sun, 13 Sep 2026 14:43:28 GMT
content-length: 23221
The saved file is the original, a 612-by-612 JPEG, served by the fallback.
error.unsupported is the only answer Viceroy has. send-to-image-optimizer and the deprecated transform-image-optimizer-request both route to a single function that ignores every argument and returns Unsupported. Read that as not supported for now: it's a gap someone may fill in later, not a decision about how the interface ought to behave. It does tell you a little more than it seems to, though. Before reaching that function, Viceroy's wrapper looks up the backend handle and unwraps the result, so an invalid backend would bring the host down. A tidy Unsupported means at least that much was right. Your request, body, and options pass through untouched, so nothing local will tell you whether those were well-formed.
What a real service needs
On a real service, Image Optimizer is a Beta program, and Fastly support enrolls the service. There's no self-service switch. image_optimizer does appear in the list of products [setup.products] accepts, but don't count on it for a Compute service. fastly products --enable=image_optimizer answers 400 image_optimizer not available for wasm services, and it answered that even on a service where support had already turned the product on. fastly products --json reports the real state, and nothing else should be trusted.
Once it's on, three things are worth knowing before the first request.
Every request that goes through Image Optimizer is a billable image request. Fastly's documentation states it without qualification: "Any request sent to Image Optimizer, including through the SDK, counts as a billable image request for the Image Optimizer product." Sending something that turns out not to be an image counts too.
Don't expect a misspelled parameter to announce itself. Fastly's Image Optimizer reference says that "all query parameters are stripped, even those that are not recognized by the image optimizer," so an unknown name is dropped rather than refused. A widht resizes nothing, the response is still a 200 carrying an image, and looking at the output is what catches it.
Image Optimizer also changes how the response is cached. The Rust SDK's documentation notes that setting Image Optimizer options overrides its custom caching controls, like surrogate keys and TTLs, and that caching follows the cache headers on the source image instead. Before- and after-send hooks don't run either.
Beyond the WIT
send-to-image-optimizer takes the request by value and returns result<response-with-body, error>. The error carries no request. So when the call fails, the request is simply gone, and the only way to fall back to serving the original image is to have kept enough information to make the request again.
The retired function borrowed its request, and its replacement consumes it, so the change was a choice. The WIT doesn't give the reason. One plausible reading is that handing the request over outright lets the host keep what it needs from the request without leaving the guest a handle to something it no longer controls. Whatever the reason, the price moves to the failure path, and a binding has to decide who pays it.
A thin binding passes it straight through to its callers. If a request type is just a wrapper around a handle, then after the call the wrapper points at nothing, and every caller that wants a fallback has to rebuild the request, the way this example does.
The Rust SDK pays it inside the binding instead. Its Request holds the host handle lazily, and before giving the handle away it runs take_handle, which reads everything still on the host side into guest memory first. The comment in lazy_handle.rs explains why: "we are about to lose access to the host-side representation." The Rust Request is still whole after the handle is gone. When Image Optimizer answers Unsupported, the SDK puts that Request inside its SendError, and into_sent_req hands it back ready to send somewhere else.
Not quite all of it comes back, and the doc comment on into_sent_req is careful to say so: "the body in the returned request is empty." The method, the URL, and the headers survive. A caller that had a request body has to attach a new one. That's still a good deal more than the thin binding leaves you holding, which is nothing.
So it's more work than the thin binding does, and a read of every header you may never need. What it buys is the difference between an ABI call that consumes its argument and a binding API that mostly behaves as though it doesn't. Wherever a WIT function takes a resource by value and can fail, a binding has that choice to make, and how much of the argument it reconstructs is the part worth deciding on purpose.
Next: access control lists, which work under Viceroy after the last post couldn't, but only if you give it a file to read.