MapLibre: “Unable to parse the tile” and “Unimplemented type: 3”
The vector tiles reach the browser, but MapLibre cannot read them and the map stays empty. Almost always the tiles are gzip-compressed and the server does not say so. How to tell the causes apart, and the fix for nginx, Apache, Caddy, S3, GitHub Pages and PMTiles.
The messages
The wording depends on the version. We reproduced each case in Chromium with MapLibre GL JS 3.3.0 and 6.13.0:
| The server sends | MapLibre 3.4.0 and later | MapLibre before 3.4.0, Mapbox GL JS |
|---|---|---|
gzip data without Content-Encoding: gzip | Unable to parse the tile at …, please make sure the data is not gzipped and that you have configured the relevant header in the server | Unimplemented type: 3 |
| data compressed twice, header sent once | the same as gzip above | Unimplemented type: 3 |
an HTML page (error page, login page, index.html) | Unable to parse the tile at …, got error: Unimplemented type: 4 | Unimplemented type: 4 |
| a PNG image (a raster tile) | Unable to parse the tile at …, got error: Unimplemented type: 3 | Unimplemented type: 3 |
The number is the field type that the protocol buffer decoder found in the first bytes, so it hints at what the bytes are. It is no error code of the tile server. The message repeats for every tile, so a map can log dozens of them at once.
Look at one tile
Copy a tile URL from the network tab of the browser and request it the way the browser does:
curl -s -D - -o tile.bin -H 'Accept-Encoding: gzip, br' 'https://tiles.example.com/14/8580/5737.pbf' \ | grep -i -e '^HTTP' -e content-type -e content-encoding head -c 4 tile.bin | od -An -tx1
| First bytes | The body is | The header must be |
|---|---|---|
1a | a vector tile, uncompressed | no Content-Encoding |
1f 8b | gzip | Content-Encoding: gzip, once |
3c (“<”) | HTML or XML: an error or fallback page | fix the URL or the routing |
7b (“{”) | JSON: usually an error message of the API | read it, it names the problem |
89 50 4e 47 | a PNG image | use a raster source |
If the body starts with 1f 8b and the header says Content-Encoding: gzip, unpack it once (gzip -dc tile.bin | head -c 4 | od -An -tx1). If it starts with 1f 8b again, the tile was compressed twice.
Why tiles are gzip-compressed
Vector tiles compress well, so most tools store them compressed: MBTiles files from tippecanoe and planetiler, PMTiles archives, and folders written by mb-util or tippecanoe -e. A tile server such as tileserver-gl or martin knows this and sends the header. A plain web server, a bucket or a CDN sees only files, sends them without the header, and the browser hands compressed bytes to MapLibre.
Fix: send the header
Each of these was tested with gzip tiles in a folder tiles/, with compression switched on for the rest of the site, and curl confirmed one Content-Encoding: gzip and a body that unpacks to a vector tile:
location /tiles/ {
gzip off; # the files are compressed already
add_header Content-Encoding gzip;
add_header Access-Control-Allow-Origin *;
types { application/vnd.mapbox-vector-tile pbf mvt; }
}
# add_header in a location replaces the add_header lines of the server block
# needs mod_headers <Directory "/var/www/html/tiles"> Header set Content-Encoding gzip Header set Content-Type application/vnd.mapbox-vector-tile Header set Access-Control-Allow-Origin * SetEnv no-gzip 1 # keep mod_deflate away from these files </Directory>
handle /tiles/* {
header Content-Encoding gzip
header Content-Type application/vnd.mapbox-vector-tile
header Access-Control-Allow-Origin *
file_server
}
# the header is stored with each object; CloudFront passes it on
aws s3 cp tiles/ s3://my-bucket/tiles/ --recursive \
--content-encoding gzip --content-type application/vnd.mapbox-vector-tile
Then reload the page with the cache emptied (or open a private window): the browser keeps the broken tiles for as long as the old cache headers allow.
Fix: unpack the tiles
Where you cannot set headers per path, as on GitHub Pages, store the tiles uncompressed. They are then larger on the way to the browser, unless the host compresses them on the fly.
# unpack a folder of gzip tiles in place find tiles -name '*.pbf' -exec sh -c 'gzip -dc "$1" > "$1.tmp" && mv "$1.tmp" "$1"' _ {} \; # or write them uncompressed in the first place tippecanoe -e tiles --no-tile-compression -zg data.geojson
Compressed twice
When the files are gzip already and the server or a CDN compresses them again, the browser undoes one layer and MapLibre gets the other. Turn off on-the-fly compression for the tile path (gzip off in nginx, SetEnv no-gzip in Apache), and check the settings of the CDN, which may compress the type application/x-protobuf.
An HTML page instead of a tile
“got error: Unimplemented type: 4” and first bytes 3c mean that the server sent a web page. Common reasons:
- A single-page app falls back to
index.htmlfor every unknown path (try_files $uri /index.html, rewrites on Netlify or Vercel), so missing tiles come back as the app with status 200. Exclude the tile path from the fallback, and answer missing tiles with 404 or 204. - A login page, a proxy error page or a “not found” page with status 200.
- A wrong URL template: a missing path part, or
{z}/{y}/{x}in the wrong order.
PMTiles
Do not unpack a PMTiles archive into a web server folder to fix this. Serve the archive as one file with HTTP range requests and let the pmtiles library read it; it unpacks the tiles itself:
import { Protocol } from "pmtiles";
maplibregl.addProtocol("pmtiles", new Protocol().tile);
// in the style:
// "sources": { "world": { "type": "vector", "url": "pmtiles://https://example.com/world.pmtiles" } }
A raster tile in a vector source
If the tile is a PNG or JPEG, the source in the style must be "type": "raster", with "tileSize": 256 for most raster services. A raster tile set can only be shown as pictures; it has no layers to style.
Or let someone else serve the tiles
overpass.ch serves vector tiles of the whole world with the right headers, ETags and CORS, in nine ready styles for MapLibre, with labels in 41 languages and 3D terrain. 100'000 tiles a month are free. Errors come back as JSON with a code.