Blog

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.

2 min read The MapSocket team

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.