Fixing a Tile Endpoint That Returns 404 or Blank Tiles
Problem statement
The map shows a grey grid. The network panel shows 404s, or 200s with empty bodies, or 200s with bodies that render nothing.
Those are four different failures with the same symptom, and the first job is to tell them apart:
- 404 for every tile โ the URL template, the path, or the tile scheme is wrong.
- 404 for some tiles โ the requests are outside the data's extent or the server's zoom range, which may be correct.
- 200 with an empty body โ the query matched nothing at that tile. Often correct, sometimes a CRS mismatch.
- 200 with a body that renders nothing โ the tile contains features under a layer name the style does not reference, or the geometry was dropped in preparation.
Only the first is unambiguously a bug. The others need one measurement each to separate a correct empty tile from a broken pipeline.
Quick answer
Request one tile you know contains data, and look at what comes back:
# a tile over central London at zoom 12
curl -s -o /tmp/tile.mvt -w "%{http_code} %{size_download} bytes\n" \
"https://api.example.org/tiles/v1/12/2047/1362.mvt"
200 18432 bytes # data โ the problem is client-side
200 0 bytes # the server produced nothing for this tile
404 0 bytes # wrong URL, or outside the served range
Then check the tile coordinates are the ones you think:
import math
def lonlat_to_tile(lon, lat, z):
n = 2 ** z
x = int((lon + 180.0) / 360.0 * n)
y = int((1 - math.asinh(math.tan(math.radians(lat))) / math.pi) / 2 * n)
return z, x, y
print(lonlat_to_tile(-0.1276, 51.5072, 12)) # (12, 2047, 1362)
Half of all "my tiles are 404" reports are a client requesting tiles for a place the data does not cover.
Step-by-step solution
1. Check the tile scheme
XYZ and TMS number the Y axis in opposite directions: y_tms = 2^z - 1 - y_xyz. A map using one against a server using the other produces tiles that exist, are the wrong place, and look like a vertically mirrored map โ or 404s near the poles.
def flip_y(z, y):
return 2 ** z - 1 - y
If your tiles look mirrored top to bottom, this is the entire diagnosis.
2. Check the bounds and the zoom range
def tile_is_valid(z, x, y, min_zoom=0, max_zoom=16):
if not min_zoom <= z <= max_zoom:
return False, f"zoom {z} is outside {min_zoom}โ{max_zoom}"
limit = 2 ** z
if not (0 <= x < limit and 0 <= y < limit):
return False, f"tile {x},{y} is outside 0โ{limit - 1} at zoom {z}"
return True, "valid"
A server that 404s outside its zoom range is behaving correctly, and a client that requests zoom 18 from a service capped at 16 will see nothing. Set maxzoom in the map's source definition, and let the client overzoom the deepest level rather than requesting tiles that do not exist.
3. Distinguish an empty tile from a broken one
An empty tile is normal: most tiles in most pyramids contain no data. The question is whether the tiles you expect to contain data are empty.
def probe_tiles(base_url, samples):
import httpx
for z, x, y in samples:
response = httpx.get(base_url.format(z=z, x=x, y=y))
print(f"{z}/{x}/{y}: {response.status_code} "
f"{len(response.content):,} bytes")
Probe a tile over known data and a tile over open ocean. Data tile empty and ocean tile empty means the pipeline is broken; data tile full and ocean tile empty means it is working.
4. Check the CRS, which is the commonest silent cause
Tile schemes are defined in EPSG:3857. If the source data is in EPSG:4326 or a national grid and the tile query does not transform it, the geometry is nowhere near the tile bounds and every tile is empty โ with no error anywhere.
-- correct: transform the bounds to the data's CRS for the index-friendly filter,
-- and the data to 3857 for the tile
select st_asmvtgeom(st_transform(t.geom, 3857), bounds.geom, 4096, 64, true)
from features t, (select st_tileenvelope(:z, :x, :y) as geom) bounds
where t.geom && st_transform(bounds.geom, 4326);
Getting either transform wrong produces empty tiles, not an error.
5. Check the layer name in the tile against the style
A vector tile contains named layers. A MapLibre style that references "source-layer": "features" against a tile whose layer is called default renders nothing โ with a 200, a non-zero body and no console error.
import mapbox_vector_tile
def tile_layers(path_or_bytes):
body = (open(path_or_bytes, "rb").read()
if isinstance(path_or_bytes, str) else path_or_bytes)
decoded = mapbox_vector_tile.decode(body)
for name, layer in decoded.items():
print(f"layer {name!r}: {len(layer['features'])} features")
if layer["features"]:
print(f" properties: {list(layer['features'][0]['properties'])[:6]}")
return list(decoded)
Compare the output with source-layer in the style. This is the single most common cause of "the tiles are fine and the map is empty".
6. Check for NULL geometry before aggregation
ST_AsMVTGeom returns NULL for geometry that vanishes at the tile's resolution. Rows with NULL geometry passed to ST_AsMVT produce features with no geometry, which render as nothing:
select st_asmvt(mvtgeom.*, 'features', 4096, 'geom')
from mvtgeom where geom is not null; -- the filter is not optional
Code examples
Example 1 โ a full tile diagnostic
import math
import httpx
def diagnose_tiles(url_template, lon, lat, zooms=(6, 10, 12, 14)):
"""Probe the pyramid over a point you know has data."""
print(f"{'z/x/y':16} {'status':>6} {'bytes':>9} note")
for z in zooms:
n = 2 ** z
x = int((lon + 180.0) / 360.0 * n)
y = int((1 - math.asinh(math.tan(math.radians(lat))) / math.pi) / 2 * n)
response = httpx.get(url_template.format(z=z, x=x, y=y))
size = len(response.content)
note = ""
if response.status_code == 404:
note = "not served โ check the zoom range and the URL"
elif size == 0:
note = "empty โ no features here, or a CRS mismatch"
elif size < 200:
note = "suspiciously small"
print(f"{f'{z}/{x}/{y}':16} {response.status_code:6} {size:9,} {note}")
flipped = 2 ** zooms[-1] - 1 - y
alt = httpx.get(url_template.format(z=zooms[-1], x=x, y=flipped))
if alt.status_code == 200 and len(alt.content) > 0:
print(f"\n! the TMS-flipped tile {zooms[-1]}/{x}/{flipped} has data โ "
f"the server and client disagree about the Y axis")
Example 2 โ checking a tile's contents against a style
import json
import mapbox_vector_tile
def check_style_against_tile(tile_bytes, style_path):
decoded = mapbox_vector_tile.decode(tile_bytes)
tile_layers = set(decoded)
print(f"tile contains layers: {sorted(tile_layers) or 'NONE'}")
for name, layer in decoded.items():
print(f" {name}: {len(layer['features'])} features")
style = json.load(open(style_path))
referenced = {layer["source-layer"] for layer in style.get("layers", [])
if "source-layer" in layer}
print(f"style references: {sorted(referenced)}")
missing = referenced - tile_layers
unused = tile_layers - referenced
if missing:
print(f"\n! the style references {sorted(missing)}, which the tile "
f"does not contain โ this renders nothing, with no error")
if unused:
print(f" (the tile also contains {sorted(unused)}, unreferenced)")
return missing
Example 3 โ serving an empty tile correctly
from fastapi import HTTPException, Response
MAX_ZOOM = 16
@app.get("/tiles/{version}/{z}/{x}/{y}.mvt")
def tile(version: str, z: int, x: int, y: int):
if not 0 <= z <= MAX_ZOOM:
raise HTTPException(404, f"zoom {z} outside 0โ{MAX_ZOOM}")
limit = 2 ** z
if not (0 <= x < limit and 0 <= y < limit):
raise HTTPException(404, f"tile {z}/{x}/{y} outside the pyramid")
body = build_tile(z, x, y)
if not body:
# an empty tile is a valid answer, not an error
return Response(b"", status_code=204,
headers={"Cache-Control": "public, max-age=3600"})
return Response(body, media_type="application/vnd.mapbox-vector-tile",
headers={"Cache-Control": "public, max-age=3600"})
Returning 204 for an empty tile, with a cache header, is worth doing: clients handle it correctly, and the cache stops the server re-deriving nothing for a tile that will always be nothing.
Explanation
Why most tiles are legitimately empty
A pyramid covers the whole world at every zoom level, and data does not. At zoom 12 there are 16.7 million tiles; a national dataset occupies a few tens of thousands of them.
So "empty tile" is the normal case and cannot be treated as an error. The diagnostic that works is comparative: a tile over known data and a tile over open ocean, and the interesting result is when both are empty.
Why a CRS mismatch produces empty tiles rather than an error
Tile bounds are computed in EPSG:3857. A spatial filter comparing those bounds against geometry in EPSG:4326 compares numbers in the millions against numbers in the tens โ they do not intersect, and no engine treats that as an error.
Every tile therefore comes back empty, at every zoom, for every location, with a 200 status. The tell is uniformity: a broken pipeline is empty everywhere, while a working one is empty only where there is no data.
Why the layer name is the commonest client-side cause
A vector tile is a container of named layers, and a style references a layer by name in source-layer. The name comes from whatever produced the tile โ ST_AsMVT's second argument, or tippecanoe's layer name โ and it is easy for the two to drift apart.
The result is a valid tile, a valid style, a 200 response with a non-empty body, and nothing on the map. Decoding one tile and comparing its layer names against the style takes a minute and resolves it immediately.
Why the Y-axis convention still causes problems
XYZ, used by almost every web map today, numbers Y from the top. TMS, the older OGC convention, numbers it from the bottom. Some servers and file formats โ MBTiles, notably โ use TMS internally while serving XYZ externally.
The symptom is either a vertically mirrored map or 404s where the flipped coordinate falls outside the data. y_tms = 2^z - 1 - y_xyz converts between them, and probing the flipped tile is a one-line test.
Edge cases or notes
- 204 is a better empty-tile response than 200 with an empty body, and much better than 404.
- Cache empty tiles. They will be empty next time too.
- MBTiles stores TMS internally โ flip Y when serving XYZ.
- Set
maxzoomon the client source so it overzooms instead of requesting tiles you do not serve. - Bounds-check
xandy, or bad requests run real queries. - A CRS mismatch is empty everywhere, which is what distinguishes it from ordinary emptiness.
ST_AsMVTGeomNULLs must be filtered beforeST_AsMVT.- CORS failures look like missing tiles in a canvas renderer โ check the console.
Internal links
- How to serve vector tiles from PostGIS with ST_AsMVT โ the server side
- Fixing a web map layer that does not appear โ the wider client-side diagnosis
- Fixing tiles that are offset or misplaced โ the TMS and CRS variants
- Web map tiles explained โ the scheme and the numbering
- Vector tiles explained โ layers inside a tile
- Fixing CORS errors on a spatial API โ when the tile is fetched and discarded
- Fixing blank or grey tiles in an embedded map โ the app-side symptom
- Dynamic tile server or static tiles: what to serve โ the serving decision
FAQ
Why do all my tiles return 404?
Usually the URL template, the zoom range or the tile scheme. Compute the tile coordinates for a known location and request that tile directly; if it 404s, compare against the TMS-flipped Y.
Is an empty tile an error?
No. Most tiles in any pyramid contain no data. Return 204 with a cache header, and worry only when the tiles you expect to contain data are also empty.
Why are all my tiles empty at every zoom?
Almost always a CRS mismatch: tile bounds are in EPSG:3857 and the data is not, so the filter never matches. Uniform emptiness is the signature.
The tiles have data but the map is blank. Why?
The style's source-layer does not match the layer name inside the tile. Decode one tile, list its layers, and compare with the style.
What is the difference between XYZ and TMS?
The Y axis direction: y_tms = 2^z - 1 - y_xyz. Mixing them gives a vertically mirrored map or 404s, and MBTiles stores TMS internally.
Should I return 404 or 204 for a tile with no data?
- A 404 makes clients retry and log errors, and it conflates "nothing here" with "wrong URL".