The Wasm Component Model on Fastly Compute
Device Detection: What are Your Visitors Using to Reach You?
August 19, 2026, by Kats Omuro (@katsuyukiomuro (opens in a new window) on X/Twitter)
A User-Agent header is just a string, so figuring out "is this a phone" sounds like a job for regex you write yourself—grep for Mobile, grep for iPhone, maintain the list as new devices ship. compute.wit skips that entirely. There's a whole interface for it, and it doesn't parse anything locally at all.
The whole interface
/// Device detection based on the User-Agent header.
interface device-detection {
use types.{error};
/// Looks up the data associated with a particular User-Agent string.
///
/// Returns a list of bytes containing JSON-encoded device data. See [here] for descriptions
/// of the JSON fields.
///
/// [here]: https://www.fastly.com/documentation/reference/vcl/variables/client-request/client-identified/ (opens in a new window)
lookup: func(user-agent: string, max-len: u64) -> result<option<string>, error>;
}
One function. You hand it a User-Agent string, it hands back option<string>—some if the string matched something in Fastly's own device database, none if it didn't. Nothing runs inside your sandbox to make that decision; lookup is a call out to a lookup table Fastly maintains on your behalf, the same shape as config-store.get or geo.lookup, just keyed on a header value instead of a store key or an IP address.
One thing worth flagging before moving on: the doc comment says lookup "returns a list of bytes containing JSON-encoded device data," but the signature it's attached to returns string, not list<u8>. That's not a contradiction so much as a stale description—a JSON string is a sequence of bytes, so the doc comment isn't wrong, just worded like it predates whatever the function's type ended up being. Worth noticing mainly because list<u8> and string aren't interchangeable everywhere in this ABI; header values are list<u8> specifically because a header isn't guaranteed valid UTF-8, and string here is a real, if perhaps accidental, promise that the JSON always is.
What comes back
The some case is a JSON blob, and the doc comment points at Fastly's own reference page (opens in a new window) for what fields it contains rather than repeating them here as WIT—they're not part of the ABI, just the shape of a JSON payload Fastly controls independently. The none case doesn't distinguish "we don't recognize this User-Agent" from "device detection wasn't available for this request"; either way, you get nothing back and no error to explain why.
Reading it directly
Full working code: full example on GitHub (opens in a new window). The buffer-retry loop from Synthesizing a Response reappears here basically unchanged, just pointed at a different function—proof that the pattern generalizes to any max-len-shaped call, not just header reads:
fn get_header_value(
request: &http_incoming::Request,
name: &str,
) -> Result<Option<String>, http_req::Error> {
let mut max_len: u64 = 128;
loop {
match request.get_header_value(name, max_len) {
Ok(Some(bytes)) => return Ok(Some(String::from_utf8_lossy(&bytes).into_owned())),
Ok(None) => return Ok(None),
Err(http_req::Error::BufferLen(needed)) => max_len = needed,
Err(e) => return Err(e),
}
}
}
fn lookup(user_agent: &str) -> Result<Option<String>, device_detection::Error> {
let mut max_len: u64 = 1024;
loop {
match device_detection::lookup(user_agent, max_len) {
Ok(Some(json)) => return Ok(Some(json)),
Ok(None) => return Ok(None),
Err(device_detection::Error::BufferLen(needed)) => max_len = needed,
Err(e) => return Err(e),
}
}
}
impl http_incoming::Guest for DeviceDetectionExample {
fn handle(request: http_incoming::Request, _request_body: http_body::Body) -> Result<(), ()> {
let user_agent = get_header_value(&request, "user-agent")
.map_err(|_| ())?
.unwrap_or_default();
let msg = match lookup(&user_agent).map_err(|_| ())? {
Some(json) => format!("User-Agent: {user_agent}\n\n{json}\n"),
None => format!("User-Agent: {user_agent}\n\nNo device data for this User-Agent.\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, msg.as_bytes()).map_err(|_| ())?;
http_resp::send_downstream(response, out_body).map_err(|_| ())?;
Ok(())
}
}
Against Viceroy, tried with both curl's default User-Agent and a full desktop-browser-style iPhone Safari string:
User-Agent: curl/8.7.1
No device data for this User-Agent.
User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1
No device data for this User-Agent.
Both come back none, even the one that unmistakably describes an iPhone. Viceroy runs entirely on the local machine with no connection back to Fastly's infrastructure, and the actual device classification data lives there, not in anything viceroy ships—so a local run has nothing to match against, regardless of what's in the header. Whether a deployed service returns real data for the same inputs hasn't been checked against a production service here; take the none responses above as "this is what local testing looks like," not as evidence about what lookup returns for real traffic.
Beyond the WIT
compute-runtime spent an entire post making the case for typed return values over strings. device-detection doesn't take that deal at all: the payload comes back as one opaque JSON string, and the ABI has nothing to say about its internal shape beyond "go read Fastly's doc page." That's a real design choice, not an oversight—Fastly's device database presumably grows and changes fields over time, and locking a WIT record to today's field set would make every addition a breaking ABI change. Leaving it as JSON means Fastly can evolve the payload without ever touching compute.wit.
That pushes a decision straight onto every SDK sitting on top of this interface: parse that JSON into something typed, or hand the caller a raw string and let them decide what to do with it. The real fastly Rust SDK has already made that call: device_detection::lookup (opens in a new window) returns an Option<Device>, not a string, and Device carries close to thirty accessor methods—device_name(), brand(), model(), is_mobile(), is_bot(), os_name(), and on down the list. That's a real promise to keep in sync with a schema the SDK doesn't control and can't version against—exactly the kind of drift open-error and kv-error sidestep elsewhere in this ABI, by being actual WIT enums the compiler can check rather than a string Fastly is free to reshape. Device hedges against it differently: every one of those accessor methods returns Option<&str> or Option<bool>, not a bare value, so a field Fastly adds or drops later just changes what comes back as None, not whether the struct still compiles. Handing back the raw string would be more honest about how little the ABI actually guarantees here, and it's still a defensible choice for a different binding—but the reference SDK's answer is to absorb the parsing itself, betting that Option everywhere is enough insurance against a schema it doesn't control.