The Wasm Component Model on Fastly Compute
Geo: And Where are Your Visitors From?
August 20, 2026, by Kats Omuro (@katsuyukiomuro (opens in a new window) on X/Twitter)
Last time, device-detection.lookup took a string and handed back option<string>—none when Fastly's database didn't recognize the input. Does geo.lookup work the same way for IP addresses—some geolocatable, some not, so option again? It doesn't. If you read the signature closely, there's no option anywhere in it.
The whole interface
/// [Geographic data] for IP addresses.
///
/// [Geographic data]: https://www.fastly.com/blog/improve-performance-and-gain-better-end-user-intelligence-geoip-geography-detection (opens in a new window)
interface geo {
use types.{error, ip-address};
/// Looks up the geographic data associated with a particular IP address.
///
/// Returns a list of bytes containing JSON-encoded geographic data. See [here] for descriptions
/// of the JSON fields.
///
/// [here]: https://www.fastly.com/documentation/reference/vcl/variables/geolocation/ (opens in a new window)
lookup: func(ip-addr: ip-address, max-len: u64) -> result<string, error>;
}
result<string, error>, not result<option<string>, error>. Every call either produces a JSON string or fails outright—there's no third case for "valid IP, no geo data for it." Same doc-comment quirk as device-detection too, saying "list of bytes" for what the signature actually types as string; not worth re-litigating a second time, since it's the same imprecise wording for the same reason.
The ip-addr parameter is the other departure from every other lookup interface met so far. device-detection.lookup and config-store.get both take a string key. geo.lookup takes an ip-address—the shared variant type from types, ipv4(tuple<u8, u8, u8, u8>) or ipv6(tuple<u16, ..., u16>), not a string you'd have to parse yourself. Building one directly in Rust looks like this:
let ip_addr = types::IpAddress::Ipv4((8, 8, 8, 8));
What "no data" looks like without option
If there's no none case, what actually happens when Fastly has nothing on an IP? The error channel absorbs it. Confirmed empirically: with no local geolocation data configured at all, calling lookup against Viceroy for any IP returns Error::GenericError, not an empty success. The interface's own shape backs this up—result<string, error> only has two branches, and "don't know anything about this IP" has to land in one of them. device-detection had a spare branch to put that case in; geo doesn't, so it goes to error instead.
Reading it directly
Full working code: full example on GitHub (opens in a new window).
let ip_addr = types::IpAddress::Ipv4((8, 8, 8, 8));
let mut max_len: u64 = 1024;
let result = loop {
match geo::lookup(ip_addr.clone(), max_len) {
Ok(json) => break Ok(json),
Err(geo::Error::BufferLen(needed)) => max_len = needed,
Err(e) => break Err(e),
}
};
let msg = match result {
Ok(json) => format!("geo.lookup(8.8.8.8):\n\n{json}\n"),
Err(e) => format!("geo.lookup(8.8.8.8) failed:\n\n{e:?}\n"),
};
The buffer-retry shape is the familiar one from every other max-len function in this series. The one new wrinkle is ip_addr.clone(): ip-address isn't a resource, so it crosses by value, and a retry loop that might call lookup more than once needs its own copy each time.
Local test data
Unlike device-detection, Viceroy's local_server supports seeding real geolocation data for local testing, the same way Config Store seeds store contents directly in fastly.toml:
[local_server.geolocation]
file = "geo.json"
format = "json"
geo.json maps an IP address to the JSON object lookup should return for it:
{
"8.8.8.8": {
"area_code": 0,
"city": "Mountain View",
"conn_speed": "broadband",
"conn_type": "wired",
"continent_code": "NA",
"country_code": "US",
"country_code3": "USA",
"country_name": "United States",
"gmt_offset": -700,
"latitude": 37.4056,
"longitude": -122.0775,
"metro_code": 0,
"postal_code": "94043",
"proxy_description": "?",
"proxy_type": "?",
"region": "CA",
"utc_offset": -700
}
}
Field names checked against Fastly's own geolocation VCL variables reference (opens in a new window) (client.geo.city, client.geo.country_code, and so on—this JSON payload is the same data, just without the client.geo. prefix VCL uses). Against Viceroy, with 8.8.8.8 requested and this file in place:
geo.lookup(8.8.8.8):
{"area_code":0,"city":"Mountain View","conn_speed":"broadband","conn_type":"wired","continent_code":"NA","country_code":"US","country_code3":"USA","country_name":"United States","gmt_offset":-700,"latitude":37.4056,"longitude":-122.0775,"metro_code":0,"postal_code":"94043","proxy_description":"?","proxy_type":"?","region":"CA","utc_offset":-700}
Delete the [local_server.geolocation] block, or request an IP that isn't a key in geo.json, and the response goes back to Error::GenericError from the section above—there's no built-in default data, and no partial match.
Beyond the WIT
geo and device-detection are close enough in shape—one function, JSON string in, JSON string out—that a naïve binding might reach for the same wrapper type for both. The option difference says that's the wrong call. A TryLookup-style API that returns null/None/nil on a miss is honest for device-detection, where a miss really is just "no data, nothing went wrong." Wrapping geo.lookup the same way would be lying to the caller: a geo miss is compute.wit's own error type, with GenericError sitting alongside real failure modes, and collapsing that into a null return would erase the distinction between "no geo data for this IP" and "something is actually broken." The two interfaces read almost identically at a glance, but any SDK wrapping both has to give them different failure behavior—a thrown exception here, a null there, or whatever the equivalent split is in that language—to stay honest about what compute.wit is actually telling it.