Measuring Tile Cache Hit Ratio at the Edge
Group edge requests by path class, divide hits by total, and compare each class against the target its cache policy implies. That single query separates a healthy deployment from one where every reader is reaching the origin — and it is the measurement that catches the four cache-key mistakes before the bill does.
When to Use This
After any change to cache headers, CDN configuration or the tile URL scheme, and as a standing panel afterwards. It is also the first thing to look at when origin CPU or egress rises without a matching rise in readers.
Specification Detail
| CDN | Cache status field | Hit values |
|---|---|---|
| Cloudflare | CacheCacheStatus |
hit, stale, revalidated |
| Fastly | fastly_info.state |
HIT, HIT-STALE |
| CloudFront | x-edge-result-type |
Hit, RefreshHit |
| Nginx as a cache | $upstream_cache_status |
HIT, STALE, REVALIDATED |
Two conventions matter when computing the ratio. A revalidated response is not a full hit — the object was served from cache but a conditional request still reached the origin, so it costs a round trip and should be counted separately. And a stale served under stale-while-revalidate is a hit from the reader’s perspective and a miss from the origin’s, which is exactly why the two views need to be reconciled.
Production Command
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,
ROUND(100.0 * COUNT(*) FILTER (WHERE cache_status = 'hit') / COUNT(*), 2) AS hit_pct,
ROUND(100.0 * COUNT(*) FILTER (WHERE cache_status = 'revalidated') / COUNT(*), 2) AS revalidated_pct,
ROUND(100.0 * COUNT(*) FILTER (WHERE cache_status = 'miss') / COUNT(*), 2) AS miss_pct
FROM cdn_logs
WHERE timestamp > now() - INTERVAL '1 hour'
GROUP BY 1
ORDER BY requests DESC;
-- path_class | requests | hit_pct | revalidated_pct | miss_pct
-- tile | 4821004 | 99.63 | 0.00 | 0.37
-- archive | 210338 | 98.81 | 0.00 | 1.19
-- style | 41204 | 2.10 | 94.30 | 3.60 <- correct
-- sprite | 38911 | 99.91 | 0.00 | 0.09
When a class misses its target, group the misses by full URL — the culprit is almost always visible in one line:
SELECT path, COUNT(*) AS misses
FROM cdn_logs
WHERE cache_status = 'miss' AND path LIKE '/v%/%.mvt'
AND timestamp > now() - INTERVAL '1 hour'
GROUP BY path ORDER BY misses DESC LIMIT 20;
-- /v43/basemap/12/2048/1362.mvt?t=1754563200 <- a cache-busting parameter
The Four Cache-Key Mistakes
A query string on the tile URL. Analytics parameters, a cache-busting timestamp, a client-appended session id — each unique value is a distinct cache object. The fix is either to remove the parameter or to configure the CDN to ignore it in the cache key, and removing it is safer because it also fixes browser caching.
Vary on a request-specific header. Vary: Accept-Encoding is fine and expected. Vary: User-Agent or Vary: Cookie multiplies the cache entries by the cardinality of that header, which for User-Agent is effectively unbounded.
Set-Cookie on a tile response. Usually inherited from a framework’s default middleware. Most CDNs treat a response with Set-Cookie as uncacheable, and a tile server sitting behind an application framework is the usual way this appears.
A TTL shorter than the interval between requests for the same tile. On a large tileset most tiles are requested rarely, so a one-hour TTL means the long tail is always cold. Versioned tiles should be immutable with a one-year TTL precisely so that rarity does not matter.
Interaction Effects
With versioned publishing. Every publish produces a new prefix and therefore a cold cache for it. Expect a brief dip after each deploy, and annotate the dashboards from the publish job so that expected dips are not investigated.
With PMTiles. The cache key for an archive is the path plus the byte range, so the hit ratio reflects range coalescing behaviour. A low ratio on archives with a high ratio on tiles suggests ranges are not being cached individually — worth confirming the CDN supports it.
With browser caching. Edge hit ratio ignores requests the browser never made. A deployment with immutable headers serves many repeat views entirely from the browser, so client-side timings will look better than any edge metric suggests.
Performance Impact
The measurement itself is a log query and costs nothing at request time. What it protects is substantial: at ten million tile requests a day, the difference between 95% and 99.5% is 450,000 origin requests versus 50,000 — an order of magnitude in origin capacity and, on a metered origin, in egress.
Common Mistakes
Reporting one aggregate ratio. Mixes classes with opposite goals and produces a number that moves for uninterpretable reasons.
Counting revalidated as a hit. It served from cache and still cost an origin round trip. Track it separately or the style document’s behaviour will look better than it is.
Alerting on an absolute threshold. Fires after every deploy. Alert on a sustained deviation from the class’s own baseline instead.
Ignoring a low archive ratio because tiles look fine. They are different cache keys with different behaviour, and an archive served from origin repeatedly is a bandwidth problem the tile panel cannot see.
FAQ
Is 99% good enough?
For versioned immutable tiles, aim higher — 99.5% or better is achievable. The remaining misses are the genuine long tail of a large tileset, which is a cost of coverage rather than a misconfiguration.
Why is my style document’s hit ratio near zero?
Because it should be. A sixty-second TTL on a document fetched once per session means nearly every request revalidates, and that is what makes version rotation propagate quickly.
Should I cache tiles in the browser as well?
Immutable headers do that automatically, and it is the cheapest hit available — no network at all. It is also invisible to edge metrics, which is why client timings are worth sampling.
Does a higher TTL always improve the ratio?
For versioned paths the TTL is already a year, so no. For unversioned paths raising the TTL trades freshness for hit rate, which is exactly the trade versioning exists to avoid making.
Related
- Tile Observability and Monitoring — the parent topic and the other three measurements.
- CDN Cache Headers & Versioned Tile URLs — the policy this measures compliance with.
- Cache-Control Headers for Immutable Vector Tiles — the header that makes 99% achievable.
- Alerting on Tile 404s and Empty Tiles — the status-code half of the same log pipeline.