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.
Production Command
The query that turns a rate into a shape — grouping 404s by zoom level and version prefix:
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:
#!/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.
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.
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.
Related
- Tile Observability and Monitoring — the parent topic and the full measurement set.
- Measuring Tile Cache Hit Ratio at the Edge — the other half of the same log pipeline.
- CI/CD Tile Build Automation — where an empty tile is cheapest to catch.
- Debugging HTTP 416 Range-Request Failures — what to do when the 416 alert fires.