Configuring Stale-While-Revalidate for Tile CDNs

stale-while-revalidate is the directive that lets a mutable tile layer be fast and fresh at the same time, and stale-if-error is what keeps it available when the origin is not. Together they decouple a consumer’s latency from the origin’s health — and, configured without a matching observability signal, they hide an origin outage so completely that every dashboard reports a healthy afternoon. This guide configures both, bounds the staleness they introduce, and adds the metric that stops them from concealing the thing they are absorbing. It applies the cache-control specification from Edge Caching and Tile Delivery Topology within Federated Ownership & Routing Architecture, and assumes the key work in Choosing Cache Keys for Vector Tile Endpoints is already done.

Prerequisites

Requirement Value / Assumption Notes
Tools curl, jq, edge CLI, Prometheus with the tile exporter Staleness must be measurable
Addressing Mutable tile addresses — no version in the path Immutable addresses need none of this
Cadence The layer’s declared update cadence The staleness bound derives from it
Access roles platform-engineer (edge config), domain-owner (staleness bound) The bound is a product decision
Environment TILE_HOST, PRODUCT, PROM Exported before running

The addressing row decides whether this guide applies at all. Under immutable addressing a tile can never change at its address, so unbounded caching is correct and revalidation is unnecessary. These directives are for layers whose address means “current”.

Step-by-Step Implementation

1. Derive the staleness bound from the product’s cadence

The bound is not a preference. It is the maximum age at which a consumer acting on the tile is still acting correctly, and it comes from the product’s declared cadence and the consequence of being wrong.

Layer character Cadence s-maxage stale-while-revalidate Worst-case age
Basemap, slow-changing Monthly 86400 604800 8 days
Cadastral parcels Quarterly 21600 86400 30 hours
Road network Daily 900 3600 75 minutes
Flood extent, operational Hourly 120 300 7 minutes
Live sensor observations Seconds 10 20 30 seconds

Worst-case served age by layer cadence, against the declared update intervalWorst-case tile age in minutes for five layer profiles, computed as shared-cache freshness plus the revalidation window, each against its own declared cadence. A monthly basemap tolerates about eight days. Quarterly cadastral parcels tolerate thirty hours. A daily road network tolerates seventy-five minutes. An hourly operational flood extent tolerates seven minutes. Live sensor observations tolerate thirty seconds. The number that belongs in a conversation with consumers is this total, not the individual directives.basemap · monthly180 min≈8 days, off scaleparcels · quarterly120 min30 hroads · daily75 minflood · hourly7 minsensors · seconds0.5 min

Worst-case age is s-maxage + stale-while-revalidate, and it is the number that belongs in a conversation with consumers — not the individual directives.

2. Set the directives at the origin

Cache-control belongs at the origin so every layer of cache receives the same instruction, rather than each edge applying its own default.

python
# tile_cache_control.py — directives derived from the layer's declared cadence.
CADENCE_PROFILE = {
    "P1M": {"s_maxage": 86_400, "swr": 604_800},
    "P3M": {"s_maxage": 21_600, "swr": 86_400},
    "P1D": {"s_maxage": 900,    "swr": 3_600},
    "PT1H": {"s_maxage": 120,   "swr": 300},
    "PT1M": {"s_maxage": 10,    "swr": 20},
}


def cache_control(cadence: str, origin_healthy: bool = True) -> str:
    """s-maxage bounds shared-cache freshness; stale-while-revalidate lets the edge
    answer immediately from cache while it refreshes in the background; stale-if-error
    keeps it answering through an origin outage rather than failing the consumer."""
    p = CADENCE_PROFILE.get(cadence, CADENCE_PROFILE["P1D"])
    return (
        f"public, max-age={min(p['s_maxage'], 60)}, "
        f"s-maxage={p['s_maxage']}, "
        f"stale-while-revalidate={p['swr']}, "
        f"stale-if-error=86400"
    )


def provenance(version: str, materialized_at: str, served_stale: bool) -> dict:
    """Degradation must be visible. A consumer that cannot tell a fresh tile from a
    stale one cannot decide whether to act on it — which turns an availability
    incident into a correctness one."""
    return {
        "X-Spatial-Version": version,
        "X-Spatial-Materialized": materialized_at,
        "X-Spatial-Freshness": "stale" if served_stale else "fresh",
    }

Verify the directives reach the edge intact — an edge that overrides them silently is a common surprise:

bash
curl -sS -o /dev/null -D - "https://${TILE_HOST}/${PRODUCT}/12/2048/1361.mvt" \
  | grep -iE '^(cache-control|age|x-spatial-freshness):'

3. Confirm revalidation happens in the background, not in the request

The whole value of the directive is that a consumer never waits on the origin. Prove it by measuring a request made immediately after expiry.

What stale-while-revalidate does to the request that arrives just after expiryA client requests a tile whose shared-cache freshness has just elapsed. Without the directive the edge fetches from the origin synchronously and the client waits for the whole round trip. With it the edge answers immediately from its stored copy, marks the response stale, and fetches the fresh object in the background so the next request is fresh. The consumer never waits on the origin, and the staleness is bounded by the window and labelled in the response.ClientEdgeOriginGET tile, just past s-maxagestored copy, marked staleno waitrevalidate in backgroundfresh object storedwithout SWR: client waits for the origin

bash
# Warm the object, wait past s-maxage, then time the next request.
curl -sS -o /dev/null "https://${TILE_HOST}/${PRODUCT}/12/2048/1361.mvt"
sleep "$(( $(curl -sS -o /dev/null -D - "https://${TILE_HOST}/${PRODUCT}/12/2048/1361.mvt" \
          | grep -i '^cache-control' | grep -o 's-maxage=[0-9]*' | cut -d= -f2) + 2 ))"

curl -sS -o /dev/null -w 'ttfb=%{time_starttransfer}s code=%{http_code}\n' \
  -D /tmp/h "https://${TILE_HOST}/${PRODUCT}/12/2048/1361.mvt"
grep -i 'x-spatial-freshness' /tmp/h
# Expect a fast response marked "stale" — the edge served from cache and is refreshing.
# A slow response marked "fresh" means SWR is not active and every expiry is synchronous.

4. Add the metric that stops the directives hiding an outage

stale-if-error is designed to make an origin failure invisible to consumers. Without an explicit metric, it makes it invisible to operators too.

Why stale-if-error needs its own metric: the outage nothing else reportsWhen the origin fails, stale-if-error has the edge keep answering from its stored copies. Consumers see no errors, availability monitoring reports a healthy service, and latency monitoring reports excellent numbers because every response is a cache hit. The only signal that anything is wrong is the ratio of responses marked stale, which is why it is recorded and alerted on. Without it, an origin can be down for hours while every dashboard reports success.Origin failingrevalidation errorsEdge serves stalestale-if-errorConsumers fineno errors seenstale_served_ratiothe only signalabsorbedinvisiblerecordedavailability: healthylatency: excellentDashboards report successthrough a multi-hour outage

yaml
# tile-staleness-rules.yaml — the signal that survives stale-if-error.
groups:
  - name: tile_staleness_recording
    interval: 30s
    rules:
      - record: tile:stale_served_ratio:5m
        expr: |
          sum(rate(tile_responses_total{freshness="stale"}[5m])) by (domain, product)
          / sum(rate(tile_responses_total[5m])) by (domain, product)

      # Age of the newest artifact any edge is serving, against the declared cadence.
      - record: tile:max_served_age_seconds:5m
        expr: max(tile_response_age_seconds) by (domain, product)

  - name: tile_staleness_alerts
    rules:
      # Elevated staleness means the origin is failing revalidation. Consumers are
      # fine; the origin is not, and nothing else will say so.
      - alert: TileOriginFailingBehindStaleIfError
        expr: tile:stale_served_ratio:5m > 0.05
        for: 10m
        labels: { severity: page }
        annotations:
          summary: "{{ $labels.product }} serving {{ $value | humanizePercentage }} stale tiles"
          description: "stale-if-error is absorbing an origin failure — check the origin, not the edge."

      # A tile older than twice the declared cadence is stale by the product's own contract.
      - alert: TileServedBeyondCadence
        expr: tile:max_served_age_seconds:5m > 2 * on(domain, product) product_cadence_seconds
        for: 15m
        labels: { severity: ticket }

Verify the alert fires by taking the origin out deliberately in a non-production environment:

bash
edgectl origin disable --product "$PRODUCT" --duration 15m
sleep 660
curl -sS "$PROM/api/v1/query" --data-urlencode \
  'query=tile:stale_served_ratio:5m{product="'"$PRODUCT"'"}' | jq -r '.data.result[0].value[1]'
# Expect a ratio near 1.0 and a firing alert, while consumers see no errors at all.

Configuration Reference

Directive Applies to Typical value Effect
max-age Browser ≤ 60 Client-side freshness; keep short
s-maxage Shared caches From cadence Shared-cache freshness; may exceed max-age
stale-while-revalidate Shared caches 2–8× s-maxage Serve stale, refresh in background
stale-if-error Shared caches 86400 Serve stale rather than fail during an outage
must-revalidate Shared caches Absent Setting it defeats both directives
X-Spatial-Freshness Response fresh / stale The metric’s source; without it, staleness is unmeasurable

must-revalidate deserves the warning. It instructs caches never to serve a stale response, which directly contradicts both directives above — and it is a frequent default in generic security-hardening configurations, applied estate-wide without anyone noticing it disabled the tile layer’s entire staleness strategy.

Common Failure Modes & Fixes

Every expiry produces a slow request. Root cause: stale-while-revalidate absent, unsupported by the edge, or defeated by must-revalidate. Fix: confirm the directive survives to the edge and remove must-revalidate from any global policy.

Origin load spikes at regular intervals. Root cause: expiries synchronised, because a whole tile range was cached at the same moment during a warm-up and now expires together. Fix: apply a small random jitter to s-maxage per object, so expiry spreads rather than aligning.

Consumers act on data hours out of date and nobody noticed. Root cause: stale-if-error working exactly as designed, with no staleness metric. Fix: the recording rules above; the ratio is the only signal that distinguishes a healthy cache from a hidden outage.

Stale ratio is permanently elevated at a low level. Root cause: a subset of tiles whose revalidation always fails — usually a range the renderer errors on. Fix: break the ratio down by zoom and tile range; a constant low ratio is a persistent partial failure, not noise.

Freshness header is absent on cache hits. Root cause: the header is set by the origin and the edge does not synthesise it when serving from cache. Fix: have the edge set it, since only the edge knows whether the object it served was stale.

FAQ

How large should stale-while-revalidate be relative to s-maxage?

Between two and eight times, and the choice is about origin protection rather than freshness. The window is how long the edge will keep answering from cache while revalidation is failing or slow, so a short window means a struggling origin quickly starts producing consumer-visible latency, and a very long one means a persistent revalidation failure goes unremarked for hours. Two to eight times gives the origin room to recover from an ordinary incident without letting a sustained failure hide. The absolute worst-case age — s-maxage plus the window — is the number to check against the product’s cadence, and it is the number consumers should be told.

Does stale-if-error mask real errors from consumers?

Yes, deliberately, and that is the right default for a tile layer. A consumer receiving a slightly-old tile is almost always better served than one receiving a 502, particularly for a basemap or a slow-changing layer. What makes it safe rather than dishonest is the freshness header: the consumer is told the tile is stale and how old it is, so a consumer for whom staleness is unacceptable can detect it and fail deliberately. Masking without labelling is what converts an availability incident into a correctness one.

Should the directives differ by zoom level?

Rarely, and only where the underlying data genuinely differs by zoom. The temptation is to cache low zooms longer because they are expensive to render, but a low-zoom tile summarises the same data as the high-zoom tiles beneath it, so caching it longer means the overview and the detail disagree — which is more confusing to a consumer than either being slightly stale. Keep one staleness bound per layer and solve expensive low zooms by precomputing them rather than by holding them longer.

What happens to these directives during a republish?

Nothing automatically, which is the limitation of mutable addressing. A republish makes every cached object stale in fact while leaving it fresh according to its directives, so consumers keep receiving the previous version until either the freshness window elapses or a purge runs. That gap is exactly what scoped purging exists to close, and it is the strongest practical argument for moving a layer to immutable addressing where its contract allows.