Diagnosing Double-Gzipped Vector Tiles
A blank map with 200 responses and no console error is almost always double compression: the tile was stored gzipped and the server compressed it again. The browser decompresses once, hands the still-compressed bytes to the protobuf parser, and the parser fails quietly. One curl identifies which hop added the second layer.
When to Use This
The signature is specific and worth recognising. Tiles return 200, Content-Length looks plausible, the network panel shows no errors, and the map draws nothing. Sometimes the same tiles render correctly from a local file server and fail behind a CDN, which narrows the search immediately.
Specification Detail
| State | Bytes begin | Content-Encoding |
Result |
|---|---|---|---|
| Raw MVT stored, no encoding | 0a … |
absent | Correct |
| Gzipped MVT stored | 1f 8b |
gzip |
Correct — the standard arrangement |
| Gzipped MVT stored, no header | 1f 8b |
absent | Client receives gzip bytes and cannot parse |
| Gzipped MVT, gzipped again | 1f 8b |
gzip |
Browser unwraps once; inner gzip reaches the parser |
The third and fourth rows are the two failure modes, and they look identical from the outside. Both produce a 200 with sensible-looking bytes.
Production Command
Ask for the tile without letting curl decompress anything, and look at the first two bytes:
URL="https://tiles.example.com/v43/roads/14/8188/5449.mvt"
# 1. What the server says it is sending
curl -sI "$URL" | grep -iE "content-encoding|content-type|content-length"
# content-encoding: gzip
# content-type: application/vnd.mapbox-vector-tile
# 2. What it actually sent, undecoded
curl -s --raw "$URL" | head -c 2 | xxd
# 00000000: 1f8b <- gzip, as declared. So far correct.
# 3. Unwrap exactly once and check again
curl -s --raw "$URL" | gzip -d 2>/dev/null | head -c 2 | xxd
# 00000000: 0a12 <- protobuf. Healthy.
# 00000000: 1f8b <- STILL gzip. Double-compressed.
Step three is the whole diagnosis. Protobuf field 1 with wire type 2 is 0x0a, and a healthy MVT starts with it. A second 1f 8b means one more gzip -d is needed than the browser will perform.
To find which hop added it, run the same three commands against the origin directly, bypassing the CDN:
# Against the bucket or the tile server, not the public hostname
curl -s --raw "https://origin.internal/v43/roads/14/8188/5449.mvt" \
| gzip -d 2>/dev/null | head -c 2 | xxd
# 0a12 -> origin is fine; the CDN added the second layer
# 1f8b -> the origin is already double-compressing
The Three Causes and Their Fixes
Stored gzipped, served without Content-Encoding. The object on disk is compressed and the server does not say so, so a compression-enabled proxy sees what it thinks is uncompressed data and compresses it. Fix: set the header on the object at upload time.
aws s3 cp roads.mvt s3://tiles/v43/roads/14/8188/5449.mvt \
--content-encoding gzip \
--content-type "application/vnd.mapbox-vector-tile"
A tile server compressing an already-compressed blob. MBTiles stores tile data gzipped; a server that gzips its responses unconditionally will double-encode. Every mainstream server handles this correctly by default, so this usually appears in custom code or in a reverse proxy configured with a blanket gzip on.
A CDN compressing on top. Exclude the tile content type from the CDN’s compression rules, or ensure the origin sets Content-Encoding so the CDN knows to leave it alone.
Interaction Effects
With PMTiles. Worse here, because the archive’s directory addresses tiles by byte offset and any transformation of the response body invalidates every offset. The symptom is not a blank tile but garbage from a range read — see range-request delivery.
With Brotli. Exactly the same failure with br in place of gzip, and slightly harder to spot because the magic bytes are less recognisable. The gzip versus Brotli comparison covers where each belongs.
With Python decoders. httpx and requests decompress transparently when the header is set, which means a script can work while the browser fails, or the reverse. Always check the first two bytes rather than trusting the library.
Performance Impact
Beyond the outage, double compression costs CPU at the compressing hop and produces a slightly larger payload than single compression — compressed data is close to incompressible, so the second pass adds framing without removing redundancy. Measured on a 118 KB gzipped tile, a second gzip pass produced 118.4 KB and cost 4 ms per request at the proxy.
Common Mistakes
Trusting Content-Length. It is the compressed length and looks entirely normal in both the healthy and the broken case.
Using curl without --raw. curl decompresses by default when the header is present, hiding the very thing you are looking for.
Fixing it by removing Content-Encoding from the stored object. That makes the CDN’s compression correct again and leaves the client receiving gzip bytes with no header the moment the CDN is bypassed. Set the header and stop the second compression.
FAQ
Why is there no console error?
Because nothing throws. The fetch succeeds, the response body is delivered, and the protobuf parser encounters bytes it cannot interpret as a valid message. MapLibre treats an unparseable tile as an empty tile, which is the same thing it does for a genuinely empty tile — a legitimate and common state.
Can I detect this in CI?
Yes, and it is worth doing: fetch one tile from the published URL, unwrap once, and assert the first byte is 0x0a. It is two lines in a smoke test and it catches every variant of this problem, including the ones introduced by a CDN configuration change nobody told you about.
Does this affect raster tiles too?
Not in the same way. PNG and WebP are already compressed and are conventionally served without Content-Encoding, so a proxy that compresses them wastes CPU but does not break them — the browser unwraps the outer layer and the image decoder sees valid bytes.
Which is worse, a missing header or a doubled encoding?
A missing header, because it fails everywhere immediately. A doubled encoding often works in some environments and not others, which is how it reaches production in the first place.
Related
- MVT Encoding Internals — the parent topic and the protobuf structure the parser expects.
- Gzip vs Brotli for Vector Tile Payloads — choosing the codec and where to apply it.
- PMTiles Range-Request Delivery — why a body transform is fatal to an archive.
- Tile Server Selection — the servers that get this right by default.