Configuring Stale-While-Revalidate Tile Caching

How to serve map tiles from cache instantly while a stale entry is refreshed in the background, so viewers never wait on a cache miss or a slow upstream render.

This walkthrough is a hands-on companion to Fallback Routing Strategies for Tile Servers and sits within the broader Core Portal Architecture & Security Boundaries practice; read the parent guide first for how fallback and cache freshness fit together at the routing edge. Here the focus is a single technique: layering stale-while-revalidate semantics across the HTTP header, the nginx cache, and MapProxy so a stale tile is returned in microseconds while a fresh one is fetched out of band.

Prerequisites

Stale-while-revalidate only helps when every layer agrees on what “stale but usable” means. Confirm these before editing any config.

  • nginx 1.19+ compiled with ngx_http_proxy_module (the stock build); background update lands in 1.11.10 and later.
  • A working MapProxy 1.16+ or GeoServer GWC upstream already producing tiles, fronted by nginx as a caching reverse proxy.
  • Write access to the nginx config and a reload path (nginx -s reload or a rolling restart of the ingress pods).
  • Agreement on freshness policy per layer: how long a tile is considered fresh, and how long past that it may be served stale while revalidating.
  • A cache volume with headroom — background revalidation briefly holds both the stale and the new tile.
  • A load-generation tool such as hey or k6 to prove that the first request after expiry is served instantly rather than blocking on the upstream.

The timeline below shows the three phases every cached tile passes through: a fresh window where it is served with no upstream contact, a stale-while-revalidating window where the same stale bytes are returned instantly while a background fetch runs, and the refreshed state once that fetch completes.

Stale-while-revalidate tile timeline A horizontal time axis runs left to right. In the fresh window the cached tile is returned directly and the upstream is never touched. At the max-age boundary the entry becomes stale; during the stale-while-revalidate window a viewer request is answered instantly with the stale tile while nginx launches a background subrequest to the tile server. When that subrequest returns, the cache entry is replaced and the timeline resets to fresh. A request arriving after both windows expire must instead block on a synchronous upstream fetch. time fresh · max-age served from cache, no upstream stale-while-revalidate stale served instantly + bg fetch refreshed entry replaced, resets to fresh max-age expires bg fetch completes background revalidation

Step-by-step implementation

1. Emit stale-while-revalidate on tile responses

The behaviour starts with the tile response advertising it. Cache-Control: max-age=... , stale-while-revalidate=... tells every conforming cache — browser, CDN, and nginx — how long the tile is fresh and how long past that it may be served stale during an async refresh. Set it on the location that proxies your tile backend.

location ~* ^/tiles/(?<layer>[a-z0-9_]+)/ {
    proxy_pass http://mapproxy_upstream;
    # 1h fresh, then up to 24h stale while a background fetch runs
    add_header Cache-Control "public, max-age=3600, stale-while-revalidate=86400" always;
    add_header X-Tile-Cache $upstream_cache_status always;
}

The X-Tile-Cache header surfaces HIT, MISS, STALE, or UPDATING so you can watch the mechanism working from a browser’s network panel. Keep max-age short enough that a genuinely changed basemap propagates in reasonable time, and stale-while-revalidate long enough to absorb a slow or briefly unavailable upstream.

2. Let nginx serve stale and refresh in the background

The header alone governs downstream caches; nginx’s own disk cache needs proxy_cache_use_stale updating plus proxy_cache_background_update on to return the stale tile immediately and fire a single background subrequest to repopulate it. proxy_cache_lock on collapses a thundering herd of concurrent misses into one upstream fetch.

proxy_cache_path /var/cache/nginx/tiles levels=1:2
    keys_zone=tiles:100m max_size=20g inactive=7d use_temp_path=off;

upstream mapproxy_upstream { server 127.0.0.1:8080; keepalive 32; }

location ~* ^/tiles/ {
    proxy_pass http://mapproxy_upstream;
    proxy_cache tiles;
    proxy_cache_key "$scheme$request_method$host$uri";
    proxy_cache_valid 200 1h;

    # serve stale instantly, refresh out of band
    proxy_cache_use_stale updating error timeout http_500 http_502 http_503;
    proxy_cache_background_update on;
    proxy_cache_lock on;
    proxy_cache_lock_timeout 5s;
}

With proxy_cache_use_stale updating, the request that trips expiry is answered from the stale entry rather than blocking; proxy_cache_background_update is what makes nginx go fetch the fresh tile asynchronously. Adding error timeout http_502 http_503 to the stale list also means a briefly down upstream degrades to stale tiles instead of blank map squares — the graceful-degradation posture detailed in Setting Up Fallback Tile Routing in Production.

3. Align MapProxy expiry and pre-refresh

MapProxy has its own freshness clock. If its cache serves tiles nginx considers fresh but MapProxy considers stale, you get double-caching surprises. Set the cache’s expiry and use refresh_before so MapProxy proactively re-renders a tile that is about to age out, keeping the bytes nginx revalidates already warm.

caches:
  basemap_cache:
    grids: [webmercator]
    sources: [basemap_wms]
    cache:
      type: file
      directory: /data/mapproxy/tiles
    # a tile older than this is re-rendered on next access
    refresh_before:
      time: '2026-07-13T02:00:00'
    # how long MapProxy considers a tile usable
    cache_rescaled_tiles: true

globals:
  cache:
    max_tile_limit: 500

Setting refresh_before to an off-peak wall-clock time means MapProxy re-renders anything created earlier than that moment, so a batch of stale tiles refreshes on the first quiet-hours access rather than during peak load. Coordinate this expiry with the nginx max-age above so the two layers never fight over who owns freshness.

4. Confirm background revalidation serves stale instantly

Prove the payoff: the first request after max-age expiry must return immediately with X-Tile-Cache: STALE or UPDATING, not stall on the upstream. Warm a tile, wait past its fresh window, then time the expiry-tripping request.

TILE="https://portal.example.org/tiles/basemap/8/72/98.png"
# warm the cache
curl -s -o /dev/null "$TILE"
# wait until just past max-age, then measure the refresh-triggering hit
sleep 3601
curl -s -o /dev/null -w 'status=%{http_code} time=%{time_total}s cache=%{header_x-tile-cache}\n' "$TILE"
#   expect: status=200 time≈0.00s cache=UPDATING   (served stale, not blocking)
# the very next request should already be fresh again
curl -s -o /dev/null -w 'cache=%{header_x-tile-cache}\n' "$TILE"
#   expect: cache=HIT

The tell is the time_total on the expiry-tripping request: a few milliseconds means nginx returned the stale tile and pushed revalidation to the background; hundreds of milliseconds means background update is not engaged and the client blocked on the render.

Verification

Validate the config parses and the headers behave under concurrency.

# 1. Config is syntactically valid before reload
nginx -t
#   expect: syntax is ok / test is successful

# 2. The tile response advertises both freshness windows
curl -sI "https://portal.example.org/tiles/basemap/8/72/98.png" | grep -i cache-control
#   expect: Cache-Control: public, max-age=3600, stale-while-revalidate=86400

# 3. Under load, only ONE upstream fetch fires per expired tile (lock working)
hey -n 500 -c 50 "https://portal.example.org/tiles/basemap/8/72/98.png" | grep -A2 "Status code"
#   expect: 500x 200 responses, no 5xx

# 4. Background updates show up as UPDATING, not MISS, in access logs
grep -c "UPDATING" /var/log/nginx/tiles_access.log

A Cache-Control header carrying both directives, zero 5xx under concurrent load, and a non-zero UPDATING count together confirm stale tiles are served instantly while revalidation runs behind them. If your OpenLayers clients live on another origin, pair this with correct cache-friendly CORS headers as covered in Managing Cross-Domain CORS for OpenLayers Clients.

Troubleshooting matrix

Symptom Likely cause Fix
First request after expiry is slow (hundreds of ms) proxy_cache_background_update not enabled or nginx older than 1.11.10 Set proxy_cache_background_update on and confirm the nginx version supports it
Every request re-fetches; X-Tile-Cache always MISS proxy_cache_key varies per request (e.g. includes a query token) Normalize the key to $scheme$host$uri; strip cache-busting query args
Upstream hammered during a spike of misses proxy_cache_lock off, so concurrent misses each fetch Add proxy_cache_lock on with a sane proxy_cache_lock_timeout
Stale tiles served far too long stale-while-revalidate window too large or MapProxy refresh_before unset Shorten the SWR window; set refresh_before so MapProxy re-renders aged tiles
Blank tiles when upstream is down proxy_cache_use_stale list omits error/timeout Add error timeout http_502 http_503 to proxy_cache_use_stale
Changed basemap takes hours to appear max-age too long for how often the source updates Lower max-age; purge affected keys, or re-seed the MapProxy cache

For how this freshness layer sits alongside the routing failover in front of it, and where GeoNode’s bundled GWC differs, see the comparison in GeoNode vs MapProxy Architecture Comparison.

Up one level: Fallback Routing Strategies for Tile Servers.