Alerting on Tile 404s and Empty Tiles

A tile deployment always returns some 404s — clients probe the edges of a tileset’s coverage — so alerting on their existence produces noise. What is worth alerting on is a step change in their rate, and the harder problem underneath: an empty tile that returns 200 and is indistinguishable from a correct one.

When to Use This

Set these alerts up once a deployment carries real traffic, and revisit them after any change to the tileset’s zoom range or layer set. They are also the fastest way to catch a style deployed against the wrong archive, which produces a characteristic 404 signature within seconds.

Specification Detail: What Each Response Means

Response Healthy? Cause when not
404 on a tile path A small steady rate is normal A step change means a style/archive mismatch
200 with an empty tile body Often correct — most tiles are empty Wrong when a populated region returns them
200 on an archive path Never Ranges are being ignored end to end
416 Never Archive replaced in place, or a truncated upload
206 Correct for archives A drop is the alarming direction

The distinction in the first two rows is the whole difficulty. A tileset’s coverage is not rectangular — a country-shaped archive returns 404 or an empty tile for every tile over the sea — so both responses are load-bearing parts of normal operation.

Three 404 patterns, and only two are problemsA diffuse background rate spread across the tileset edge, a sudden step affecting one zoom band, and a total loss affecting every path under a prefix.SIGNATURESDiffusecoverage edgesOne zoom bandrange mismatchWhole prefixpublish failedstyle maxzoom exceeds thearchivestyle points at a prefixnever uploaded
The shape distinguishes them. A rate alone cannot: the same number of 404s can be entirely normal or a complete outage.

Production Command

The query that turns a rate into a shape — grouping 404s by zoom level and version prefix:

sql
SELECT
  split_part(path, '/', 2)                            AS version_prefix,
  split_part(path, '/', 4)::int                       AS zoom,
  COUNT(*)                                            AS not_found,
  COUNT(DISTINCT path)                                AS distinct_tiles
FROM cdn_logs
WHERE status = 404
  AND path LIKE '/v%/%/%/%/%.mvt'
  AND timestamp > now() - INTERVAL '15 minutes'
GROUP BY 1, 2
ORDER BY not_found DESC;

--  version_prefix | zoom | not_found | distinct_tiles
--  v43            |   15 |     84210 |          38402   <- style asks for z15, archive stops at z14
--  v43            |   12 |       312 |            298   <- normal edge-of-coverage trickle

Two columns make the diagnosis. A single zoom dominating means the style’s declared maxzoom exceeds the archive’s. A high not_found with low distinct_tiles means a handful of tiles are being requested repeatedly — usually one broken layer rather than a range problem.

And the check for empty tiles that should not be, run as a synthetic probe rather than from logs:

bash
#!/usr/bin/env bash
# Probe a handful of tiles known to be populated. Anything under ~200 bytes
# is an empty MVT, whatever the status code says.
set -euo pipefail
BASE=https://tiles.example.com/v43/basemap

while IFS=/ read -r Z X Y; do
  BYTES=$(curl -sf --compressed -o /dev/null -w '%{size_download}' "$BASE/$Z/$X/$Y.mvt")
  if [ "$BYTES" -lt 200 ]; then
    echo "EMPTY: $Z/$X/$Y returned $BYTES bytes"
    exit 1
  fi
done <<'TILES'
12/2048/1362
14/8188/5449
10/511/340
TILES
echo "all probe tiles populated"

Those three addresses are chosen once, from populated areas, and committed. A probe that fails means either the archive lost data or the style is pointed somewhere unexpected — both worth waking someone for, and neither visible in an error-rate dashboard.

Five alerts that fire on real breakage onlyAlerts on 404 step changes, any 416, a 200 on an archive path, an empty probe tile, and origin-to-edge ratio deviation.THE ALERT SET404 rate deviates from its weekly baseline for 15 minutescatches a style/archive mismatch without firing on deploysAny 416 responsean archive was replaced in place or truncated — never healthyAny 200 on a .pmtiles pathranges have stopped working; clients are pulling whole archivesA probe tile returns under 200 bytesthe only check that catches an empty-but-valid responseOrigin-to-edge request ratio above baselinethe cache stopped working, with no error anywhere
Three of these fire on any occurrence, because there is no healthy rate for them. The other two need a baseline.

Why an Empty Tile Is the Hard Case

A vector tile with no features is a valid, well-formed protobuf a few dozen bytes long, and it is the correct response for most tile addresses in most tilesets. Nothing in the transport layer distinguishes it from the empty tile a broken build produces.

That leaves three practical detection strategies, in increasing order of confidence. Byte-size thresholds on known-populated addresses, as above — cheap, and catches wholesale failures. Feature-count assertions in the build gate, before anything is published, which is where the problem is cheapest to catch. And a rendered smoke test comparing a screenshot against a reference, which is the only method that catches a tile with features that are somehow wrong.

The build gate is the one to invest in. By the time an empty tile is being served, the archive is already published and the fix is a rollback; the same check before the publish step turns it into a failed build.

Three ways to catch an empty tile, and where each belongsByte-size probes, build-gate feature counts and rendered smoke tests compared on when they run, what they catch and what they cost.DETECTING EMPTY TILESrunscatchescostByte-size probeAfter publishWholesale failure3 requestsBuild-gate feature countBefore publishAny empty regionOne decodeRendered smoke testBefore publishWrong content, not justabsentA browser
The build gate is the one to invest in. By the time a probe fires, the archive is already published and the fix is a rollback.

Interaction Effects

With versioned publishing. Every deploy briefly raises the 404 rate as clients holding an old style request the previous prefix after it has been retired. Retaining old prefixes well past the style TTL keeps that at zero — see versioned rotation.

With zoom range declarations. The single most common 404 signature is a style whose source maxzoom exceeds the archive’s. Asserting the two agree in CI removes the alert’s most frequent cause.

With the build gate. Everything here is a second line of defence. The publication gates are the first, and a deployment that relies only on monitoring is detecting problems readers have already seen.

Performance Impact

Log-based alerts cost nothing at request time. The synthetic probe is three requests per interval — negligible, and worth running from more than one region, since a CDN failure is often regional and a single-region probe will miss it entirely.

Common Mistakes

Alerting on any 404. Produces constant noise from normal edge-of-coverage requests and gets muted within a week.

Treating an empty tile as an error. Most tiles in most tilesets are empty. Only specific known-populated addresses are meaningful probes.

Probing a tile at the edge of coverage. It may be legitimately empty, and the alert will flap.

Only alerting on status codes. The two worst failures in this section — an empty archive and a cache that stopped working — both return 200 throughout.

FAQ

What is a normal 404 rate?

It depends entirely on tileset shape and client behaviour; a country-shaped archive sees more than a global one. Establish the baseline from a week of traffic rather than picking a number.

Should the tile server return 404 or an empty tile for a missing tile?

Either is defensible and consistency matters more than the choice. An empty 200 avoids error noise and hides genuine gaps; a 404 is honest and makes monitoring easier. Most static deployments return 404 because the object genuinely does not exist.

How many probe tiles are enough?

Three to five, spread across zoom levels and regions. The goal is detecting wholesale failure, not coverage testing.

Can I detect an empty tile without decoding it?

Byte size is a good proxy — an empty MVT is under a hundred bytes and a populated one is rarely under a thousand. Decoding gives certainty at the cost of a dependency in the probe.