The Wasm Component Model on Fastly Compute
Shielding: Sending Your Traffic Through One POP on Its Way to Origin
September 15, 2026, by Kats Omuro (@katsuyukiomuro (opens in a new window) on X/Twitter)
Fastly runs your Compute program in points of presence (opens in a new window), or POPs, spread around the world. A visitor's request lands at a nearby POP, and when your program there needs something from your origin server, it sends the request straight to that origin. Every POP does this on its own. A page that isn't cached yet can reach your origin once from every POP where a visitor asks for it.
Shielding changes where those requests go. You pick one POP to be the shield, and the rest send their requests to it instead of to your origin.
What shielding does
Fastly's shielding guide (opens in a new window) describes it this way: "When Shielding is enabled, visitor requests from across the global network funnel through a single, designated shield POP instead. This ensures that all user traffic converges at a singular entry point before reaching your origin, which can reduce upstream load."
Visitors land at the same POPs they always did. What changes is the next hop. A POP that isn't the shield is called an edge POP, and it forwards to the shield POP. The shield POP is the only one that talks to your origin.
The guide lists three benefits. Shielding "reduces the volume of requests from Fastly to your origin servers." It "increases the probability of end user requests resulting in a cache HIT (albeit potentially not from the first POP which handles the request)," because a miss at the edge can still be a hit at the shield. And it "reduces connection setup latency for MISS and PASS requests," which sounds backwards for an extra hop until you read why: "all Fastly POPs always have a pool of open connections to all other Fastly POPs."
It only matters for requests that go to a backend. In the guide's words, "Shielding is not involved when the request is handled entirely at the edge, e.g., on a synthetic response or a static asset edge hit."
Edge POPs and shield POPs
The shield POP is still an ordinary POP. Visitors who are closest to it land there directly, the same as at any other POP, and for them there's no edge POP in the path at all.
You name a shield by its shield identifier, which isn't the POP's three-letter code. Amsterdam's POP is AMS, the value get-pop from the post on the compute runtime would report there, but its shield identifier is amsterdam-nl. The guide suggests choosing one "physically close to your origin servers," and weighs a couple of other factors, such as private interconnects with cloud providers and the POP's cache capacity.
If you're familiar with Fastly's CDN offering, shielding there is a setting on a backend, and Fastly routes the second hop for you. A Compute service has no such setting. The guide says "shielding is implemented programmatically using your language SDK's Shielding API": your program asks whether it's running on the shield POP, and if it isn't, the API gives it a tunnel that "behaves like a standard Backend." Your program sends the request through the tunnel itself.
That tunnel leads to another copy of your program.
The guide calls this double execution. When a visitor lands at an edge POP, your program runs there, finds it isn't on the shield, and forwards. Then it runs again at the shield POP, finds that it is, and sends the request to origin. When a visitor lands on the shield directly, it runs once.
Which run should do what
A program that runs twice for one request can do things twice. Say your origin is a storage bucket that wants a path prefix, and your program adds it to the request URL. Run that at both POPs and, as the guide points out, the bucket receives /bucket-name/bucket-name/path/to/file. More generally, the guide warns that code which ignores where it's running "can lead to redundant processing, duplicate headers, or issues like cache poisoning."
So for each edit your program makes to a request or a response, you decide which run makes it. The guide sorts them into two kinds.
Edits to the request that are meant for your origin, like that bucket prefix or a different Host header, are what the guide calls origin-bound. They belong at the shield POP, in the run whose next hop is your origin. (For the prefix, the guide also suggests checking whether it's already there, so a second run changes nothing.)
Edits to the response that are meant for the visitor, like setting Cache-Control: no-store, private or stripping debugging headers, are client-facing. They belong at whichever POP the visitor is connected to. On the double-execution path, that's the edge POP. On the single one, it's the shield POP.
Finding the client-connected run takes more work. The guide's approach for Compute is a header of your own, because "the internal Fastly-FF header is not available in Compute services." The edge POP adds it just before forwarding, carrying a secret from a Secret Store (the kind the Secret Store post covered). A run that doesn't see that header with the right value is the one talking to the visitor.
Getting the response side wrong costs more than a duplicate header. A response your program edits at the shield POP arrives at the edge POP looking like it came from your origin, and the edge POP can cache it. The guide says so plainly: "A mistake here can accidentally poison your edge caches."
A few more effects follow from the extra hop, and the guide covers each one:
- Fastly identifies your service by the
Hostheader on the internal hop too, so if the edge POP rewrites it, the shield POP fails the request with a500. - At the shield POP, the downstream connection comes from the edge POP, so the client IP you'd normally read is the edge POP's. The visitor's is in the
Fastly-Client-IPheader. - A miss at the edge followed by a hit at the shield is recorded as one of each, so your cache hit ratio can look lower than you'd expect.
- "Traffic from one Fastly POP to another will count towards your request count and billable bandwidth," though the guide adds that "in more realistic scenarios, shielding will often reduce costs overall."
The ones your code can do anything about all start with it knowing which run it's in, and that's what the interface is for.
The whole interface
/// [Shielding] API.
///
/// [Shielding]: https://www.fastly.com/documentation/guides/concepts/shielding/ (opens in a new window)
interface shielding {
use types.{error};
use backend.{backend};
shield-info: func(
name: string,
max-len: u64,
) -> result<string, error>;
/// Options for `backend-for-shield`.
resource shield-backend-options {
constructor();
set-cache-key: func(cache-key: string);
set-first-byte-timeout: func(timeout-ms: u32);
set-between-bytes-timeout: func(timeout-ms: u32);
}
backend-for-shield: func(
name: string,
options: option<borrow<shield-backend-options>>,
) -> result<backend, error>;
}
It has two functions and an options resource. Apart from the link at the top and the one line on the options resource, there are no doc comments at all. Neither function says what it does, and neither says what its string means.
Set against the guide, the roles are clear enough. shield-info asks whether you're running on the shield POP, and backend-for-shield is the tunnel.
What the interface leaves out is how the question gets answered.
Asking where you are
shield-info: func(
name: string,
max-len: u64,
) -> result<string, error>;
You pass a shield identifier and get back a string. The obvious reading is a single name or status. There's no doc comment to say otherwise, so the quickest way to find out is to call it and look. On a POP that isn't the shield, the example later in this post gets this back:
shield-info("pdx-or-us") = Ok("\0http://127.0.0.1:7677/\0http://127.0.0.1:7677/\0")
On the shield, it gets this:
shield-info("pdx-or-us") = Ok("\u{1}\0\0")
That's a record. The first byte is a flag, nonzero if this POP is the shield you asked about. Two strings follow, each ending in a NUL byte. The first is an unencrypted target and the second is an encrypted one. On the shield, both targets are empty, because there's nowhere to tunnel to, so the whole string is three bytes.
This isn't a quirk of the local test server. A real Fastly service returns the same layout, though its targets look different, as you'll see below.
The tunnel as a backend
backend-for-shield: func(
name: string,
options: option<borrow<shield-backend-options>>,
) -> result<backend, error>;
backend-for-shield returns a backend that represents the tunnel through the shield. This is really convenient, because it lets you use it with send as we have been doing since Calling a Backend by Hand. Like the backend resource registered by Dynamic Backends, it doesn't come from your service configuration. The host makes it on request.
The parameter is name, the same word shield-info uses for its shield identifier. Going by the name alone, you'd think you're supposed to pass the shield identifier again.
Not so simple, it seems. It turns out you're going to want to pass in one of the two targets we just pulled out of the shield-info string. Which one depends on whether you want an object that represents the encrypted backend or the unencrypted one.
So the two functions only make sense as a pair. The output of the first is a record, and one field of that record is the input to the second. Unfortunately, neither signature tells you so today.
The options resource is ordinary by comparison: a constructor and three setters, all optional. Two of them are timeouts, named like the pair on dynamic backend options.
The third, set-cache-key, is harder to pin down. There's no doc comment to say which cache it keys or how. Alas, this post can say nothing about this function other than the fact that it exists.
Running both hops locally
Full working code: full example on GitHub (opens in a new window).
In production, there's nothing to enable or configure for the shield itself. You pick the POP in code, by its shield identifier. The only backend the example declares is its origin:
[setup]
[setup.backends]
[setup.backends.origin]
address = "http-me.fastly.dev"
description = "http-me test origin"
port = 443
For local testing, Viceroy needs to be told which shield identifiers exist and where they are. That goes in [local_server.shielding_sites], and each entry takes one of two forms. The string "Local" means "Viceroy is running on this shield." A table with encrypted and unencrypted URLs means the shield is somewhere else, and those URLs are what come back as the two targets.
To see both hops, the example runs the same program twice. fastly.toml makes this instance an edge POP, with pdx-or-us on port 7677:
[local_server]
[local_server.backends]
[local_server.backends.origin]
url = "https://http-me.fastly.dev"
# This instance plays an edge POP. The shield is somewhere else: the second
# instance, started from fastly.shield.toml on port 7677.
[local_server.shielding_sites]
"pdx-or-us".unencrypted = "http://127.0.0.1:7677"
"pdx-or-us".encrypted = "http://127.0.0.1:7677"
A second manifest, fastly.shield.toml, is identical except for the site itself:
# This instance plays the shield POP itself.
[local_server.shielding_sites]
"pdx-or-us" = "Local"
The Fastly CLI picks fastly.<env>.toml when you pass --env, so the two instances start like this:
fastly compute serve --env=shield --addr=127.0.0.1:7677
fastly compute serve
Both URLs for the remote site are plain HTTP, even the one labeled encrypted. Viceroy doesn't check that the encrypted URL uses HTTPS. Pointed at an HTTPS server, the backend it built for the shield failed certificate validation when this example tried it, so the local setup sidesteps TLS on that hop entirely.
Here's the handler:
mod bindings;
use bindings::{
exports::fastly::compute::http_incoming,
fastly::compute::{backend, http_body, http_req, http_resp, shielding},
};
fn read_all(body: &http_body::Body) -> Vec<u8> {
let mut out = Vec::new();
while let Ok(chunk) = http_body::read(body, 4096) {
if chunk.is_empty() {
break;
}
out.extend(chunk);
}
out
}
fn respond(text: String) -> Result<(), ()> {
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(|_| ())?;
http_body::write(&body, text.as_bytes()).map_err(|_| ())?;
http_resp::send_downstream(response, body).map_err(|_| ())
}
/// The layout the SDKs read out of `shield-info`'s string: one flag byte,
/// then the unencrypted and encrypted targets, each followed by a NUL.
fn parse_shield_info(info: &str) -> Option<(bool, &str, &str)> {
let (flag, rest) = info.split_at_checked(1)?;
let mut parts = rest.split('\0');
let unencrypted = parts.next()?;
let encrypted = parts.next()?;
if parts.next() != Some("") || parts.next().is_some() {
return None;
}
Some((flag != "\0", unencrypted, encrypted))
}
fn send_to(
lines: &mut String,
label: &str,
request: http_req::Request,
body: http_body::Body,
to: &backend::Backend,
) {
match http_req::send(request, body, to) {
Ok((response, response_body)) => {
let status = response.get_status().unwrap_or_default();
lines.push_str(&format!("{label:<24}{status}\n"));
// Indent what came back, so each hop's report nests inside the last.
for line in String::from_utf8_lossy(&read_all(&response_body)).lines() {
lines.push_str(&format!(" {line}\n"));
}
}
Err(e) => lines.push_str(&format!("{label:<24}Err({:?})\n", e.error)),
}
}
struct Shielding;
impl http_incoming::Guest for Shielding {
fn handle(request: http_incoming::Request, request_body: http_body::Body) -> Result<(), ()> {
let shield = match request.get_header_value("shield", 64) {
Ok(Some(v)) => String::from_utf8_lossy(&v).into_owned(),
_ => "pdx-or-us".to_string(),
};
let mut lines = String::new();
let info = shielding::shield_info(&shield, 1024);
lines.push_str(&format!("shield-info({shield:?}) = {info:?}\n"));
let parsed = info.as_deref().ok().and_then(parse_shield_info);
if let Some((running_on, _, _)) = parsed {
lines.push_str(&format!("{:<24}{running_on}\n", "running on shield"));
}
if let Some((false, _unencrypted, encrypted)) = parsed {
// Not on the shield: build a backend that points at it, and forward.
let options = shielding::ShieldBackendOptions::new();
options.set_first_byte_timeout(15_000);
match shielding::backend_for_shield(encrypted, Some(&options)) {
Ok(to_shield) => {
lines.push_str(&format!("{:<24}{:?}\n", "backend-for-shield", to_shield.get_name()));
send_to(&mut lines, "forwarded to shield", request, request_body, &to_shield);
return respond(lines);
}
Err(e) => lines.push_str(&format!("{:<24}Err({e:?})\n", "backend-for-shield")),
}
}
// On the shield, or shielding isn't available: go to origin directly.
// This is the last hop, so it's the only safe place to change the Host header.
request.insert_header("host", b"http-me.fastly.dev").map_err(|_| ())?;
let origin = backend::Backend::open("origin").map_err(|_| ())?;
send_to(&mut lines, "sent to origin", request, request_body, &origin);
respond(lines)
}
}
bindings::export!(Shielding with_types_in bindings);
Every hop writes a short report, and a hop that forwards indents whatever came back beneath the lines it wrote. The last hop's body is whatever http-me was asked to say.
Send a request to the edge instance:
curl http://127.0.0.1:7676/body=hello-from-origin
shield-info("pdx-or-us") = Ok("\0http://127.0.0.1:7677/\0http://127.0.0.1:7677/\0")
running on shield false
backend-for-shield "******http://127.0.0.1:7677/*****"
forwarded to shield 200
shield-info("pdx-or-us") = Ok("\u{1}\0\0")
running on shield true
sent to origin 200
hello-from-origin
That's double execution, written out. The same handler ran twice for one request. At the edge, the string led with \0 and carried both targets, so the program built a backend from the encrypted one and forwarded. At the shield, the string was three bytes and led with \u{1}, so the program went to origin.
Two details in there are Viceroy's rather than the ABI's. The targets are the URLs from fastly.toml, normalized with a trailing slash, and the backend's name, with its asterisks, is simply what Viceroy calls the backends it makes for shields. On a real service the layout of the string is the same and its contents aren't: the targets come back as opaque backend identifiers such as INTER-AMS-PLAIN, not URLs. That's fine, because nothing is supposed to look inside one. The SDKs only hand it back.
Now send the request straight to the shield, the way a visitor who happens to be closest to Portland would arrive:
curl http://127.0.0.1:7677/body=hello-from-origin
shield-info("pdx-or-us") = Ok("\u{1}\0\0")
running on shield true
sent to origin 200
hello-from-origin
The handler ran once. When the shield is the nearest POP, there's no edge hop to skip.
Finally, name a shield identifier that the manifest doesn't list:
curl -H "shield: waffle-cone" http://127.0.0.1:7676/body=hello-from-origin
shield-info("waffle-cone") = Err(Error::InvalidArgument)
sent to origin 200
hello-from-origin
shield-info fails with error.invalid-argument, and the request still gets an answer. That's deliberate. Fastly's guide tells you to fail open, so a service whose shield lookup fails for any reason sends the request straight to origin rather than returning an error to the visitor.
Beyond the WIT
shield-info returns a record, and the WIT calls it a string.
That wouldn't be unusual on its own. Several functions in compute.wit pack more than one value into a single return. get-header-values, from Synthesizing a Response, puts a NUL after each value in a list<u8>. downstream-original-header-names, from the post on who's actually asking, does the same thing inside a string. Each of those functions has a doc comment that describes the layout: what's encoded, in what order, and what separates it.
shield-info has no doc comment at all. Its layout is written down only in code that parses it, in the same SDK sources the aside above turned to. It also isn't just text with separators. The first byte is a boolean that happens to be 0 or 1, which makes it a valid character but not a meaningful one.
The max-len parameter hints at where this came from. Before the Component Model, the hostcall behind shield-info wrote its answer into a buffer the guest provided. The witx signature the Go SDK quotes for it names that buffer info_block. The component interface kept the parameter and the bytes and changed the type label to string. A record with a bool and two string fields would have said the same thing in a form every binding generator understands.
Nothing has broken because of this, but anyone building a binding has three extra things to get right.
First, the encoding has to survive your string type. If you're using a language whose strings carry a length, as Rust, Go, and C# strings do, a leading \0 is just a character. If yours are NUL-terminated anywhere along the way, for instance a C char * passed through a helper, shield-info reads as an empty string exactly when you're not on the shield. That's the common case. The failure is silent: an empty string parses as neither a flag nor two targets, and the code that consumes it most likely decides shielding is unavailable and fails open. Every request goes straight to origin, the service keeps working, and the shield never sees any traffic.
Second, the parse should be strict. A lenient parser that takes "whatever's between the NULs" will keep producing targets if the layout ever grows a field. They'll just be the wrong targets. It's safer to check the whole shape, the way the Rust SDK does when it insists on exactly one empty segment after the second target, and to fail loudly if it doesn't match. An error is much cheaper to find than requests that go to the wrong place without complaint.
Third, the targets arguably shouldn't be strings in your API at all. They exist to be handed back to backend-for-shield, and that function's parameter is also called name, with nothing in its type to distinguish a target from a shield identifier. If you expose the targets, callers have to know which of three strings goes where. If you offer an "encrypted backend" and an "unencrypted backend" instead and keep the targets private, as both the Rust and Go SDKs do, callers can't pass the wrong one. The WIT hands you an untyped string. How much of that looseness your callers inherit is up to your binding.
Next: a request you never answer. WebSocket passthrough hands the connection to a backend, and after that your program can't send a response at all. It also runs in Viceroy, so we'll set it up in fastly.toml for both local testing and production.