The tile bug that only browsers could see
Our tile proxy served perfect tiles to curl and broken ones to MapLibre. The cause was a gzip header that outlived its body.
MapSocket’s tile edge is a thin proxy. It checks the API key, meters the request, and forwards it to the tilefeed
worker for that source. tilefeed stores tiles gzipped and serves them with Content-Encoding: gzip, which is what
browsers expect from a vector tile server.
During testing, maps rendered blank. The tile requests all returned 200. Fetching the same URL with curl and
decoding it gave a perfectly valid Mapbox Vector Tile. The browser console said the tile couldn’t be parsed.
Two layers of helpfulness
The proxy used fetch, the one built into Node. Like a browser, Node’s fetch decompresses response bodies for you:
when the upstream says Content-Encoding: gzip, the bytes you read are already inflated.
The proxy then copied the upstream headers onto its own response, including Content-Encoding: gzip. So the browser
received plain protobuf bytes with a header promising gzip. It tried to inflate them, failed, and handed MapLibre
garbage.
curl didn’t hit this because without --compressed it doesn’t decode anything. It saved the plain bytes, and those
were fine.
The fix
A proxy should forward bytes, not interpret them. We swapped fetch for a plain node:http request on the
tile path, which hands back the upstream body exactly as sent, and we now forward only the headers we mean to:
// node:http, with no decoding: the body is exactly what the worker sent
const upstream = await rawGet(`${worker}/${source}/${z}/${x}/${y}`, headers);
const out: Record<string, string> = { "content-type": "application/vnd.mapbox-vector-tile" };
for (const h of ["etag", "content-encoding", "cache-control"]) {
const v = upstream.headers[h];
if (typeof v === "string") out[h] = v;
}
return c.body(upstream.body, 200, out);
That also saves work: the proxy no longer inflates every tile just for the browser to receive it uncompressed.
Keeping it fixed
The regression test starts a fake worker that serves a known tile gzipped, requests it through the edge, and checks
two things: the response still says gzip, and the raw bytes gunzip back to the original tile. If anyone puts a
decoding client back on the tile path, that test fails.
The lesson we wrote on the whiteboard: any time a body passes through your code, check who decoded it and whether the headers still describe what you’re sending.