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.

Where the second layer of compression comes fromStored gzipped tiles are correct when served with a matching Content-Encoding; a server or CDN that compresses again produces bytes the parser cannot read.ENCODING STATESStored gzipped1f 8b on diskServer re-gzipssees no encodingParser failssilentlyContent-Encoding not set onthe stored objectbrowser unwraps once, innergzip remains
The tile is not corrupt at any point. Every layer did something reasonable — the problem is that two of them did the same reasonable thing.

Production Command

Ask for the tile without letting curl decompress anything, and look at the first two bytes:

bash
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:

bash
# 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
Asking each hop what it is sendingThe same range-free request issued against the origin, then the CDN, then observed in the browser, isolating the hop that introduces the second compression layer.curlOriginCDNGET tile, no accept-encoding games1f 8b, unwraps to 0a — healthysame request, public hostname1f 8b, unwraps to 1f 8bthe CDN compressed it again
Whichever hop first returns bytes that are still gzip after one unwrap is the one to fix.

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.

bash
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.

What the first two bytes tell you at each unwrapThe first two bytes of a tile response read raw, after one gzip decompression, and after two, showing which sequence indicates a healthy tile and which indicates double compression.FIRST BYTES1f 8braw responsegzip, as declared —correct so far0a 12after one gunzipprotobuf — healthy1f 8bafter one gunzipstill gzip — doublecompressed
A healthy MVT begins 0a — protobuf field 1, wire type 2. Anything that is still 1f 8b after one unwrap has been compressed twice.

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.