Cache & CDN Invalidation Testing
When map tiles, vector style bundles, sprite sheets, or glyph PBFs fail to invalidate predictably, a visual regression run captures whatever the edge happened to be holding — a stale basemap under a fresh overlay, a half-rotated style, or a coordinate projection that no longer matches the live data. The diff then fails for a reason that has nothing to do with cartography, and the report buries a genuine regression under cache noise. Cache and CDN invalidation testing is the discipline of proving, before any screenshot is taken, that every tier of the caching hierarchy — browser HTTP cache, service worker, CDN edge, origin shield, and origin tile server — has converged on the exact asset versions the test expects.
This page extends the masking and stability work described in Dynamic Element Masking & UI Stability: masking neutralizes volatile on-screen elements, while cache invalidation neutralizes volatile upstream state, and a baseline is only trustworthy when both are controlled.
What Cache Invalidation Means for Map Assets
In a web mapping context, “the asset” is rarely a single file. A single rendered frame is composed from a raster or vector tile grid, a style.json, one or more sprite atlases (sprite.png + sprite.json), and glyph ranges fetched per font stack. Each of these is independently cacheable and independently versionable, so invalidation is a set operation: the test must assert that the whole composition is coherent, not that one URL returned 200.
The HTTP contract is governed by standard freshness and revalidation semantics. The MDN Web Docs on HTTP Caching define how Cache-Control: max-age, s-maxage, stale-while-revalidate, ETag, and Last-Modified interact across browsers and shared caches. Two properties matter most for deterministic capture:
- Strong validators. An
ETagderived from a content hash lets every tier answer a conditional request with304 Not Modifiedfor unchanged grids and force a full200 OKonly when bytes actually changed. Weak validators (W/"…") are not sufficient when subpixel rendering is being diffed. - Immutability with fingerprinting. Append a content hash to the request path (
style.7f3a9c.json,sprite.7f3a9c.png) and serve it withCache-Control: public, max-age=31536000, immutable. A new render is a new URL, so invalidation collapses into deployment rather than purging.
The OGC Tile Map Service and the XYZ slippy-map convention both key a tile by z/x/y, which means the content of z/x/y changes whenever the underlying data or style changes, but the URL does not. That mismatch — a mutable resource at a stable URL — is the root cause of nearly every stale-tile false positive, and the architecture below exists to resolve it. The deeper version-keying model lives in Baseline Management for Tile Servers; the techniques here are how you prove the live edge agrees with that key at capture time.
Architecture of the Caching Hierarchy
A request for a tile traverses, in order: the browser HTTP cache, any registered service worker Cache Storage, the CDN edge node nearest the runner, an optional origin shield, and finally the origin tile server. Each tier can independently hold a stale copy, and a purge that reaches the edge but not the service worker still yields a stale render.
The recommended structural approach rests on three load-bearing choices:
- Surrogate-key tagging at the edge. Tag every tile and style response with cache tags (Fastly surrogate keys, Cloudflare
Cache-Tag, CloudFront via invalidation paths) such asstyle:7f3a9c,layer:roads, andz:14. Purging by tag clears exactly the affected composition without a wildcard purge that would cold-start the entire grid and inflate capture latency. - Immutable fingerprints for everything addressable. Styles, sprites, and glyphs carry a content hash in the path. Only the raster/vector tile endpoints stay at stable
z/x/yURLs, and those are the only resources that ever need an active purge. - Origin shielding to serialize purges. A shield tier absorbs the purge once and revalidates downstream edges from a single warm copy, so a distributed test fleet does not stampede the origin and observe inconsistent intermediate state.
The service worker tier deserves special attention because it sits below the network from the page’s perspective and will happily serve a cached tile even after every server-side tier is purged. A map application’s service worker must either version its cache names (tiles-v7f3a9c) and delete stale buckets on activate, or expose a test-time message channel that flushes Cache Storage on demand. Without that, you are diffing the contents of the client’s disk, not the deployment.
Step-by-Step Invalidation Verification
The verification harness runs as a pre-flight stage in the deployment pipeline, before the visual suite is allowed to capture. It snapshots current validators, executes the purge, confirms propagation across edges, and only then green-lights capture.
The numbered procedure the diagram encodes:
-
Snapshot current validators. For every critical asset —
style.json, each sprite sheet, and a representative tile sweep — record the liveETag,Last-Modified, andCache-Controlso the post-purge state can be diffed against a known baseline.for url in \ "$CDN/styles/main/style.json" \ "$CDN/sprites/main/sprite.png" \ "$CDN/tiles/14/8645/5293.pbf"; do curl -sI "$url" \ | grep -iE '^(etag|last-modified|cache-control|cf-cache-status|x-cache):' \ | sed "s#^#$url #" done -
Execute a tag-scoped purge. Trigger invalidation through the CDN API using the surrogate key for the deployed style hash, never a wildcard path, to keep the purge blast radius minimal.
# Cloudflare: purge by cache tag rather than full-zone curl -sX POST \ "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/purge_cache" \ -H "Authorization: Bearer $CF_API_TOKEN" \ -H "Content-Type: application/json" \ --data "{\"tags\":[\"style:${STYLE_HASH}\",\"sprite:${STYLE_HASH}\"]}" -
Confirm propagation from every edge region. Dispatch concurrent
HEADrequests from geographically distributed runners and assert each one reports a purged/miss state before anyGETwarms it.// Assert every edge reports a purge before capture is allowed. const regions = ['iad', 'lhr', 'fra', 'syd', 'gru']; const results = await Promise.all(regions.map(async (pop) => { const res = await fetch(`https://${pop}.edge.example.com/styles/main/style.json`, { method: 'HEAD', cache: 'no-store', }); const status = res.headers.get('cf-cache-status') ?? res.headers.get('x-cache'); return { pop, status, etag: res.headers.get('etag') }; })); const stale = results.filter((r) => !/MISS|PURGED|EXPIRED/i.test(r.status ?? '')); if (stale.length) throw new Error(`Stale edges: ${JSON.stringify(stale)}`); -
Assert freshness on the warming
GET. Re-request each asset and verify it now carries the deployment’s content hash and the expectedmax-age, proving the new version — not a re-warmed stale copy — is what the next capture will see. -
Flush the client tiers in the test browser. Bypass the browser HTTP cache and block service workers so the page composes its frame from freshly fetched assets rather than disk. This is the bridge between server-side invalidation and what the comparator actually photographs.
// Playwright: deterministic client-side cache state for capture. const browser = await chromium.launch({ args: ['--disable-application-cache', '--disk-cache-size=0', '--no-sandbox'], }); const context = await browser.newContext({ serviceWorkers: 'block', bypassCSP: true, }); // Belt-and-braces: force-revalidate every request during capture. await context.route('**/*', (route) => { const headers = { ...route.request().headers(), 'cache-control': 'no-cache' }; route.continue({ headers }); }); -
Release the capture gate. Only after steps 3 and 4 pass for the full asset set does the harness hand control to the screenshot stage. Capture synchronization from that point — waiting for tiles to hydrate — is owned by Handling Async Tile Loading, and the broader capture pipeline by Screenshot Capture, Sync & Comparison Logic.
Webhook-driven purges should fire only after origin deployment and asset fingerprinting both succeed; a purge that races ahead of the origin leaves edges revalidating against the old origin and re-caching stale bytes — the single most common way an “invalidation” makes the problem worse. Standardize the header directives across environments against the Cloudflare Cache Control Guidelines so staging and production cannot drift apart.
Cross-Browser & Cross-Environment Considerations
Cache heuristics are not uniform across engines, and a purge strategy validated only on Chromium will still produce stale renders on WebKit or Firefox.
- Chromium honours
--disk-cache-size=0and the--disable-application-cacheflag, and respectsserviceWorkers: 'block'at the context level. Its heuristic freshness (caching a no-Cache-Controlresponse for ~10% of theLast-Modifiedage) means tile endpoints must send explicit directives. - WebKit (Safari / Playwright
webkit) ignores several Chromium-specific flags; the reliable lever is request-levelCache-Control: no-cacheinjected via routing, pluscache: 'no-store'on programmaticfetch. WebKit is also stricter about partitioned (per-origin) Cache Storage, so a shared service worker cache across subdomains will not behave as on Chromium. - Firefox (Gecko) applies its own heuristic freshness window and keeps a separate in-memory cache that survives soft reloads; launch with
browser.cache.disk.enable=falseandbrowser.cache.memory.enable=falsepreferences for a clean capture environment.
In containerized CI, pin the browser image (the same pinned-image discipline that stabilizes fonts and GL drivers) so that a cache-behaviour change in a browser point release cannot silently alter results. Distributed runners should resolve the CDN through deterministic DNS or a fixed edge PoP per shard; otherwise two shards hit two edges in different purge states and disagree. Where transient UI chrome appears during the cold-cache reflow — spinners, skeleton tiles, progress bars — suppress it with Animation & Transition Suppression and exclude the interactive layer per Interactive Overlay Masking Rules so a slow first paint after a purge does not register as a regression.
Threshold & Parameter Reference
These are the configurable values that govern an invalidation gate. Treat them as starting points and tighten per CDN SLA. Propagation budget can be expressed as
| Parameter | Recommended value | Rationale |
|---|---|---|
| Edge purge propagation budget | < 500 ms (enterprise CDN) | SLA ceiling before capture is allowed to start |
| Propagation poll backoff | 100 ms base, ×2, max 5 tries | Exponential backoff with jitter absorbs uneven edge timing |
Tile Cache-Control (active testing) |
no-cache, must-revalidate |
Forces conditional revalidation on every capture |
Fingerprinted asset max-age |
31536000, immutable |
Styles/sprites/glyphs never need active purge |
Background terrain stale-while-revalidate |
60 |
Tolerable only for non-critical, non-diffed layers |
| Edge regions polled per gate | ≥ 5 (multi-continent) | Detects partial-propagation before capture |
| Capture retry on propagation timeout | 3, exponential backoff | Avoids flaky failures from a single slow edge |
| Validator type | strong ETag (content hash) |
Weak validators allow subpixel-divergent 304s |
For non-critical background layers (base terrain, hillshade), a short stale-while-revalidate is acceptable because those layers are typically masked or low-weighted in the diff. Foreground vector layers under active test must use no-cache or must-revalidate so the comparator never sees a revalidation-in-progress frame. How much per-pixel tolerance to allow once the right bytes are confirmed is a separate calibration covered by Dynamic Threshold Configuration.
Common Pitfalls
Purge fired before origin deployment finished
Root cause: the invalidation webhook triggered on commit or build start rather than on a confirmed origin deploy, so edges revalidated against the old origin and re-cached stale bytes. Diagnose: compare the ETag returned by the warming GET against the deployed artifact hash; a mismatch on a freshly purged URL confirms the race. Fix: gate the purge webhook on a successful origin health check and asset-fingerprint step, and re-poll edges after the purge rather than assuming instant convergence.
Service worker serves stale tiles after a clean CDN purge
Root cause: the application service worker holds tiles in versionless Cache Storage and is not flushed by the test, so the page never reaches the network. Diagnose: in the failing run, log caches.keys() and the from-service-worker flag on tile responses; cached hits with the old hash confirm it. Fix: version cache names by style hash and delete stale buckets on activate, or block service workers entirely in the capture context with serviceWorkers: 'block'.
Wildcard purge cold-starts the grid and inflates capture latency
Root cause: purging by path wildcard (/tiles/*) evicts the entire grid, so the first capture pays a full origin round-trip per tile and times out or photographs a half-filled grid. Diagnose: correlate a spike in x-cache: MISS density and tile-load duration with the purge event. Fix: purge by surrogate key scoped to the changed style:hash/layer:id and let an origin shield re-warm edges from one copy before the gate opens.
Heuristic freshness caches a directive-less tile endpoint
Root cause: a tile response with no Cache-Control is cached by the browser for a fraction of its Last-Modified age, so a “no-cache” intent silently becomes minutes of staleness. Diagnose: inspect the tile response headers; an absent Cache-Control with a present Last-Modified is the signature. Fix: send explicit Cache-Control on every tile and style response and inject request-level no-cache during capture for engines that ignore launch flags.
Edges disagree because shards hit different PoPs mid-propagation
Root cause: two parallel shards resolved the CDN to two different edge nodes that were in different purge states, producing one pass and one fail for the same view. Diagnose: log the resolved edge PoP and cf-cache-status per shard; divergent PoPs with divergent statuses confirm it. Fix: poll all target regions in the gate before capture, or pin each shard to a fixed PoP and only release once every polled edge reports a purge.
Cache misbehaviour that survives all of the above usually manifests as residual rendering noise rather than a clean stale frame; the preprocessing filters in Noise Reduction for Map Artifacts are the last line of defence, but they are no substitute for a correct purge.
Related
- Dynamic Element Masking & UI Stability — the parent discipline this page extends; masking volatile on-screen elements alongside upstream cache control.
- Interactive Overlay Masking Rules — excluding popups, tooltips, and chrome that surface during a cold-cache reflow.
- Animation & Transition Suppression — freezing spinners and skeleton tiles that appear after a purge.
- Marker Cluster Stability — stabilizing data-driven marker grouping that shifts with re-fetched feature data.
- Handling Async Tile Loading — gating capture on full tile hydration once the cache is confirmed fresh.
- Baseline Management for Tile Servers — the version-keying model that invalidation must agree with.
← Back to Dynamic Element Masking & UI Stability