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.

The reason stale-while-revalidate matters operationally is not the millisecond it saves one reader — it is what it does to the origin at the moment a hot tile crosses its max-age. A basemap tile over a capital city can be requested a few hundred times a second. Without a serve-stale rule every one of those requests becomes a cache miss the instant the entry expires, and each miss is a full render at the upstream WMS. The renderer sees a vertical wall of identical work, its worker pool saturates, response times climb past the proxy’s proxy_read_timeout, and the timeouts convert a cache expiry into a partial outage. This is the classic cache stampede, and on a tile plane it is worse than on an HTML cache because the render cost per miss is measured in hundreds of milliseconds of CPU rather than a database round trip.

With stale-while-revalidate plus proxy_cache_lock, the same expiry produces exactly one upstream fetch. The first request through the door takes the lock and triggers the background refresh; every other request in that window is answered from the stale entry immediately, at cache latency, and never touches the renderer at all. The two panels below contrast the upstream fan-out in each case for the same burst.

Upstream fan-out at expiry: blocking revalidation versus stale-while-revalidate Left panel: six client requests arrive at an expired cache entry, all six arrows continue to the upstream renderer, which is labelled six concurrent renders and a queue that grows. Right panel: the same six requests are answered from the stale entry with a single dashed arrow continuing to the upstream as one background revalidation. WITHOUT stale-while-revalidate WITH stale-while-revalidate + cache lock 6 requests in one burst tile just crossed its max-age cache 6 x MISS renderer 6 concurrent GetMap renders queue grows every reader waits for a render p99 tracks renderer queue depth timeouts surface as 504s 6 requests in one burst same burst, same expiry cache 6 x UPDATING stale served renderer 1 background revalidation no reader waits for a render p99 stays at cache latency upstream load is flat across the expiry

The practical consequence is that the lock is not optional. stale-while-revalidate on its own still allows every concurrent miss to start its own background fetch, which reduces reader latency but leaves upstream load unchanged. It is the pairing of the two directives — serve stale to readers, admit exactly one revalidation — that flattens the load curve, and it is the pairing that the verification step below actually measures.

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.

Choosing the two windows for a given layer

max-age and stale-while-revalidate answer two different questions, and conflating them is the most common way this configuration goes wrong. max-age is a correctness statement: how long a tile may be served without anyone checking whether the underlying data moved. stale-while-revalidate is an availability statement: how far past that point you would still rather show a slightly old tile than show nothing. A layer can legitimately have a short max-age and a very long stale window — that combination says “refresh often, but never let a renderer outage become a blank map.”

Drive both numbers from how the layer is actually published rather than from a site-wide default. The decision below is the one to walk for each layer in the cache configuration.

Choosing max-age and stale-while-revalidate per layer A decision tree rooted at the question of publication cadence, branching into three leaves: scheduled republication, continuous republication, and legally current layers, each with a recommended pair of cache windows and the reason for it. How is this layer republished? ask per layer, not per portal On a schedule nightly ETL, weekly imagery max-age just under the publication gap stale window: hours to a day the tile cannot go stale early, and an outage during the run never blanks the map Continuously sensor feeds, edited features short max-age minutes, not hours stale window: tens of minutes refresh often enough to track the feed, but keep a buffer wider than one render Must be current statutory or safety layers very short max-age no stale window revalidate before serving accept the latency cost and protect the origin with the lock alone

Two rules keep the numbers honest once they are set. First, the stale window must always be wider than the worst-case render time for that layer, or the background refresh will still be running when the stale entry expires and readers fall through to a blocking miss anyway — a seeding-heavy layer with a two-minute render needs minutes of stale headroom, not seconds. Second, publish the pair as configuration rather than as a header written by hand at the edge: MapProxy’s expiry and the proxy’s Cache-Control must agree, because whichever is shorter silently wins and the other becomes a comment. The tuning MapProxy cache seeding for large extents guide covers the seeding side of that agreement, where the refresh is driven ahead of the expiry instead of by reader traffic.

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.