Tile Observability and Monitoring
A misconfigured tile delivery tier does not fail. It serves every request correctly, from the wrong place, at the wrong cost — and the only symptoms are an origin bill that tracks reader traffic and a map that feels slower than it should. Four measurements catch essentially all of it, and none of them requires more than the CDN’s own logs.
Prerequisites
| Requirement | Why |
|---|---|
| CDN logs with the request path | Every measurement below is grouped by path class |
| A stable path convention | /v43/… versus /style.json must be distinguishable |
| Origin request metrics | The number that should not track reader traffic |
| Optional: a client timing beacon | For time to first tile |
Core Concept: Measure by Path Class, Never in Aggregate
An aggregate cache hit ratio for a tile deployment is close to meaningless, because the deployment deliberately contains resources with opposite caching goals. Versioned tiles should approach 100%. The style document should approach 0%, because its TTL is sixty seconds by design. Averaging them produces a number that moves for reasons nobody can interpret.
Group by path class instead, and each class gets a target it can be held to:
| Path class | Example | Target hit ratio | What a miss means |
|---|---|---|---|
| Versioned tiles | /v43/basemap/12/2048/1362.mvt |
> 99% | Something is varying the cache key |
| Versioned archive ranges | /v43/basemap.pmtiles |
> 98% | Cold region, or ranges not cached |
| Sprite and glyphs | /v43/sprite.png |
> 99% | Rarely a problem; loud when it is |
| Style document | /style.json |
~0% | Correct by design |
Step-by-Step Implementation
Step 1 — Classify requests in the log pipeline
-- Whatever the log store, the shape is the same: derive a class, then group.
SELECT
CASE
WHEN path LIKE '/v%/%.pmtiles' THEN 'archive'
WHEN path LIKE '/v%/%.mvt' THEN 'tile'
WHEN path LIKE '/v%/sprite%' THEN 'sprite'
WHEN path LIKE '/fonts/%' THEN 'glyphs'
WHEN path = '/style.json' THEN 'style'
ELSE 'other'
END AS path_class,
COUNT(*) AS requests,
SUM(CASE WHEN cache_status = 'HIT' THEN 1 ELSE 0 END)::float / COUNT(*) AS hit_ratio,
SUM(CASE WHEN status >= 400 THEN 1 ELSE 0 END) AS errors
FROM cdn_logs
WHERE timestamp > now() - INTERVAL '1 hour'
GROUP BY 1
ORDER BY requests DESC;
Step 2 — Watch origin request rate against reader traffic
These two series should be almost uncorrelated once the caches are warm. Origin traffic that rises and falls with reader traffic means the edge is holding nothing, whatever the hit-ratio panel says — and the two can disagree when the CDN reports a hit for a request it then revalidates.
The cheapest version of this check is a ratio: origin requests divided by edge requests, plotted over time. On a healthy versioned deployment it sits near zero and steps up briefly after each publish.
Step 3 — Watch the status-code mix, not the error rate
Total error rate is another aggregate that hides the interesting signal. The individual codes each mean something specific:
| Code | On tiles, means |
|---|---|
404 |
The style requests a zoom or layer the archive does not carry |
416 |
An archive was replaced in place, or a multipart upload truncated |
403 |
Bucket policy or signed-URL expiry |
206 |
Correct for range reads — a drop here is the alarming direction |
200 on an archive path |
Ranges are being ignored; clients are downloading the whole file |
That last row deserves an alert of its own. A 200 where a 206 is expected means every client is fetching the entire archive, which is a bandwidth incident that looks like success in every error dashboard.
Step 4 — Alert on shapes, not thresholds
A fixed threshold on tile hit ratio produces false alarms after every publish and misses slow degradation. Three alerts that hold up:
- Origin-to-edge request ratio above a multiple of its weekly baseline, sustained for fifteen minutes. Catches a cache key change without firing on a deploy.
- Any
416at all, immediately. There is no healthy rate of range-not-satisfiable; one means an archive was replaced under a live prefix. 200responses on an archive path, immediately. Ranges have stopped working end to end.
Deliberately absent from that list is a threshold on 404 rate, because a small steady trickle is normal — clients probe zoom levels at the edges of a tileset’s range. What is worth alerting on is a step change, which usually means a style deployed against the wrong archive.
Step 5 — Sample what readers actually experience
Everything above measures infrastructure. None of it measures the thing that matters, which is how long a reader waits before the map looks like a map. Two numbers cover it, and both come from the browser’s own resource timings:
Time to first tile. The interval from map initialisation to the first tile response completing. On a warm edge this is dominated by network round-trip time and should sit in the low hundreds of milliseconds; a value in seconds means either a cold path or an extra hop — a TileJSON fetch, a leaf directory, a redirect — nobody accounted for.
Time to a complete viewport. When the last tile of the initial view arrives. This is the number that tracks perceived quality, and it is sensitive to request count in a way the first-tile number is not, which is why it degrades sharply when a style grows to four sources.
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (!/\.(mvt|pbf|pmtiles)(\?|$)/.test(entry.name)) continue;
navigator.sendBeacon("/beacon/tile-timing", JSON.stringify({
url: entry.name,
duration: Math.round(entry.duration),
transferSize: entry.transferSize, // 0 means served from the browser cache
protocol: entry.nextHopProtocol,
}));
}
});
observer.observe({ type: "resource", buffered: true });
transferSize of zero is worth capturing specifically: it distinguishes a browser-cache hit from an edge hit, which no server-side metric can see. A deployment where most repeat views never leave the browser is doing better than its CDN hit ratio suggests.
Sample this rather than collecting it from every session. A one-percent sample gives stable percentiles on any deployment with real traffic, and it keeps the beacon from becoming a meaningful fraction of the requests it is measuring.
What Not to Measure
It is worth naming the metrics that look useful and are not, because instrumenting them costs effort and produces dashboards nobody can act on.
Average tile latency. Dominated by cache hits, which are uniformly fast, so it barely moves when the interesting thing — the miss path — degrades. Track the 95th and 99th percentiles instead, where misses actually live.
Total bytes served. Interesting to finance and useless operationally. It rises with traffic, which is the intended outcome, and it says nothing about whether the delivery tier is working.
Tile counts per zoom level. A property of the tileset, fixed at build time, and unchanging until the next publish. It belongs in the build’s output, not in a monitoring dashboard.
Error rate as a single number. Every specific code in the table above means something different and demands a different response. Collapsing them into one percentage discards the entire diagnostic value.
Optimization Knobs
| Knob | Conservative | Aggressive | Trade-off |
|---|---|---|---|
| Log sampling | 100% | 1% | Full logs cost storage; 1% is plenty for ratios and misses rare errors |
| Metric granularity | Per path class | Per tile path | Per-path cardinality explodes on a tile deployment |
| Client timing beacon | None | Every session | Real timings are the only reader-facing number, at the cost of a beacon request |
| Retention | 7 days | 90 days | A weekly baseline needs at least a month to be meaningful |
Integration With Adjacent Pipeline Stages
Upstream, publishing is what causes the expected step changes. A monitor that does not know when a version prefix rotated will read every deploy as an incident — annotate the dashboards from the publish job.
Sideways, the cache header configuration is what these measurements are measuring. Nearly every finding here resolves to a header, a query string or a Vary.
Downstream, nothing consumes these metrics but people. That makes the alert list the important artefact: a dashboard nobody watches is worth less than three alerts that fire correctly.
Troubleshooting
Hit ratio fell without a deploy
Cause: Something began varying the cache key — a query string appended by a client, a Vary header added at the origin, or a Set-Cookie on tile responses.
Diagnosis: Group the misses by full URL and look for a parameter. One appearing in every miss and no hit is the culprit.
Origin CPU tracks reader traffic
Cause: The edge is not caching at all, usually because tile responses carry no Cache-Control and the CDN’s default is short.
Fix: Set the header at the origin — see cache-control headers for immutable tiles.
A burst of 404s after a style deploy
Cause: The style declares a zoom range wider than the archive, or points at a prefix that was never uploaded.
Fix: Compare the style’s declared range against the archive’s own metadata, and verify the publish step ran before the flip.
Annotating the Dashboards
Every measurement in this topic has an expected discontinuity at publish time, and a dashboard that does not know when a publish happened will present each one as an anomaly. Emitting a deploy marker from the publish job — the version prefix, the timestamp, the commit — turns a confusing step in the hit-ratio panel into a labelled event, and it is three lines in the CI job that already knows all three values.
In-Depth Guides
Measuring Tile Cache Hit Ratio at the Edge — extracting the number per path class, and the cache-key mistakes that quietly destroy it.
Alerting on Tile 404s and Empty Tiles — distinguishing a healthy trickle from a broken deploy, and catching the empty tile that returns 200.
Rate Limiting Public Tile Endpoints — protecting an open tile endpoint without breaking legitimate viewport bursts.
FAQ
What is a realistic hit ratio for versioned tiles?
Above 99% on a deployment with steady traffic. Below that, something is varying the cache key or the TTL is too short for the traffic pattern.
Should I monitor per-tile metrics?
No. A tile deployment has millions of distinct paths, and per-path cardinality will overwhelm any metrics system. Group by path class, and drop to individual URLs only when investigating.
How do I measure time to first tile?
A PerformanceObserver for resource timings on tile URLs, reporting the first one that completes after a map load. It is the only measurement here that reflects what a reader experiences rather than what the infrastructure did.
Does an empty tile show up as an error?
No — an empty tile is a valid response with a 200 and a small body, and it is often correct. Catching the ones that should not be empty needs a content check rather than a status check.
Related
- CDN Cache Headers & Versioned Tile URLs — the configuration these measurements verify.
- Tile Server Selection — where origin CPU comes from when tiles are generated rather than stored.
- PMTiles Range-Request Delivery — why a
200on an archive path is an incident. - Publishing Tilesets to R2 from a CI Job — the event that explains every expected step change.
- Tile Serving & CDN Delivery — the parent section.