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.

Triage table of four tile failures and what each indicates.
Probe one tile over known data and one over open ocean; compare.

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
Two tile grids showing the XYZ and TMS Y-axis conventions.
MBTiles stores TMS internally while serving XYZ externally.

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.

Two panels contrasting normal tile emptiness with the uniform emptiness of a CRS mismatch.
Return 204 with a cache header for a genuinely empty tile, not a 404.

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 maxzoom on the client source so it overzooms instead of requesting tiles you do not serve.
  • Bounds-check x and y, or bad requests run real queries.
  • A CRS mismatch is empty everywhere, which is what distinguishes it from ordinary emptiness.
  • ST_AsMVTGeom NULLs must be filtered before ST_AsMVT.
  • CORS failures look like missing tiles in a canvas renderer โ€” check the console.

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?

  1. A 404 makes clients retry and log errors, and it conflates "nothing here" with "wrong URL".