cache-coherence-patterns-reference
Pure-reference catalog of cache-coherence patterns across the request path. Defines the five-tier cache stack (browser → CDN → reverse-proxy → application → data store), the per-tier cache-writing patterns (cache-aside, write-through, write-back, write-around, refresh-ahead), and the canonical invalidation strategies (TTL-only, event-driven purge, surrogate keys, version-tagged URLs, soft purge), plus an anti-pattern table and a worked multi-tenant coherence-test example. Deep detail lives in references/: RFC 9111 Cache-Control / Vary / ETag directive tables, the cross-tier test surface, cache-stampede (thundering-herd) mitigations incl. the XFetch formula, and RFC 5861 stale-while-revalidate / stale-if-error semantics. Use for pattern selection, Cache-Control header design, coherence audits, stampede-refresh strategy, and SWR/SIE window design; use a cache-key-collision check when the question is whether two concrete requests collide on a key scheme.
Install with skills.sh (any agent)
npx skills add testland/qa --skill cache-coherence-patterns-referencecache-coherence-patterns-reference
Overview
Keeping cached values consistent with their source of truth across tiers (browser, CDN, reverse-proxy, application, data store). Wrong coherence shows as stale data; wrong invalidation shows as cache stampedes per references/stampede.md. A pure reference consumed by per-tier test skills.
When to use
How to use this reference
The five-tier stack
| Tier | Where | Common TTL | Invalidation |
|---|---|---|---|
| Browser | Cache-Control: private | minutes-hours | TTL only (or Service Worker code) |
| CDN | Cloudflare / Fastly / CloudFront / Akamai | seconds-days | Purge API or surrogate-key tag |
| Reverse proxy | Varnish, nginx | seconds-hours | VCL purge / nginx cache_purge |
| Application | Redis / Memcached / in-process | seconds-minutes | Direct delete / pub-sub broadcast |
| Data store | Postgres query cache, RDS read replicas | seconds | Replication-driven |
A coherence bug at any tier surfaces at the user. The test surface is layered; each tier needs its own coherence tests.
Cache-writing patterns
For application-tier caches (Redis):
| Pattern | Flow | When |
|---|---|---|
| Cache-aside (lazy load) | Read miss → read source → populate → return; Write → invalidate cache | Read-heavy, eventual consistency OK |
| Write-through | Write → write source → write cache (synchronous) | Strong consistency, latency tolerable |
| Write-back | Write → write cache → async write to source | Burst writes; data-loss risk on cache crash |
| Write-around | Write → write source (skip cache); reads do cache-aside | Write-heavy with rare re-reads |
| Refresh-ahead | Background refresh before TTL expires | Predictable read patterns; hot keys |
Invalidation strategies
| Strategy | Mechanism | Trade-off |
|---|---|---|
| TTL-only | Just let it expire | Simple; possibly-stale window = TTL |
| Event-driven purge | Source-of-truth update fires a delete | Coupling; firehose at high write rate |
| Surrogate keys (Fastly, Varnish) | Tag responses; purge by tag | Group-invalidation; coordination cost |
| Version-tagged URLs | /api/users?_v=42; new version = new key | Immutable cache; full deploy per change |
| Soft purge | Mark stale, keep serving until refresh | Used by stale-while-revalidate per references/stale-while-revalidate.md |
Worked example: a multi-tenant dashboard endpoint
Scenario: /api/users serves per-tenant dashboard data, is read-heavy, and must never leak one tenant's rows to another. Walk the four decisions from How to use this reference, then the test.
Coherence test (the browser-tier "write → reload → see old" case):
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Cache-Control: public on per-user data | Shared cache leaks data | Use private for user-specific |
Missing Vary: Authorization | Cross-tenant leak | Add to Vary or set private |
s-maxage longer than session lifetime | Logged-out users see another user's data | Match TTL to security window |
| TTL but no purge | Stale-window = TTL even for urgent updates | Implement purge API + use surrogate keys |
ETag generated per-request from now() | Defeats the validation | Stable ETag from content hash |
no-cache instead of no-store for sensitive data | Browser still stores; just revalidates | no-store, no-cache, must-revalidate, private |
| Browser TTL = CDN TTL = origin TTL | Multi-tier amplifies staleness instead of layering it | Origin lowest, CDN longer, browser shortest |
| Cache-aside without write-then-invalidate | Reads see pre-write state for TTL window | Always invalidate on write |
Vary: * | Disables shared cache entirely | Use specific headers |
| Single Cache-Control for HTML + JSON + assets | One-size doesn't fit; HTML often short, assets long | Per-route directives |
Deep references
The contract layer and the audit-and-test surface live in companion references so this file stays a decision surface:
Limitations
References
Cross-tier coherence problems and the per-tier test surface
View source (opens in new window)Cross-tier coherence problems and the per-tier test surface
Deep reference for cache-coherence-patterns-reference SKILL.md. Consult when auditing an existing multi-tier cache for coherence bugs and when deciding what to test at each tier.
Cross-tier coherence problems
A coherence bug at any tier surfaces at the user; the failure usually lives in the seam between two tiers, not inside one.
| Problem | Where | Detection |
|---|---|---|
| Browser caches stale page after server purge | Browser ignores must-revalidate, or no must-revalidate | E2E test: write → reload → see old |
| CDN serves stale after origin update | Purge didn't propagate or s-maxage too long | E2E: write → purge → read at CDN edge |
| Different Vary at browser vs CDN | CDN strips headers; cache keys diverge | Header-comparison test |
| Layered TTL inversion | s-maxage < max-age → CDN refreshes more often than browser; browser eventually outpaces CDN | Audit the TTL stack |
Vary: Cookie without normalised cookies | Tracker cookies fragment cache; near-zero hit rate | Inspect Vary; normalise |
| Tenant-scoped data with shared Vary | Cross-tenant leak per cross-tenant-data-leak-tests | Add Authorization to Vary or use private |
Testable behaviours by tier
Each tier needs its own coherence tests; the categories below map to the per-tier test skills that consume this reference.
| Tier | Test categories |
|---|---|
| Browser | Cache-Control respected (max-age, no-cache, must-revalidate); ETag round-trip; Vary honoured |
| CDN | Edge hit/miss vs origin; purge API works end-to-end; s-maxage overrides max-age |
| Reverse proxy | VCL purge (varnish-test-vtc-syntax); grace-mode behaviour |
| Application | Cache-aside write-then-invalidate; key collisions |
| Data store | Replication lag (separate concern; out of scope here) |
RFC 9111 HTTP caching directives
View source (opens in new window)RFC 9111 HTTP caching directives
Deep reference for cache-coherence-patterns-reference SKILL.md. Consult when designing the Cache-Control, Vary, and ETag contract that the browser and CDN tiers enforce.
Per www.rfc-editor.org/rfc/rfc9111.html (opens in new window):
Response directives (server → cache)
| Directive | RFC ref | Meaning |
|---|---|---|
max-age=N | §5.2.2.1 | "The response is to be considered stale after its age is greater than the specified number of seconds." |
s-maxage=N | §5.2.2.10 | "For a shared cache, the maximum age specified by this directive overrides... max-age." |
no-cache | §5.2.2.4 | "The response MUST NOT be used to satisfy any other request without forwarding it for validation." |
no-store | §5.2.2.5 | "A cache MUST NOT store any part of either the immediate request or the response." |
must-revalidate | §5.2.2.2 | "Once the response has become stale, a cache MUST NOT reuse that response... until it has been successfully validated." |
private | §5.2.2.7 | "A shared cache MUST NOT store the response (intended for a single user)." |
public | §5.2.2.9 | "A cache MAY store the response even if it would otherwise be prohibited." |
immutable | RFC 8246 | Response body will not change for the lifetime of the URL. Browsers skip revalidation. |
Per RFC 9111 §4.2.4: "A cache MUST NOT generate a stale response unless it is disconnected or doing so is explicitly permitted by the client or origin server." This is the formal basis for stale-while-revalidate per stale-while-revalidate.md (opens in new window).
Vary - the cache key
Per RFC 9111 §4.1: "When a cache receives a request that can be satisfied by a stored response and that stored response contains a Vary header field, the cache MUST NOT use that stored response without revalidation unless all the presented request header fields nominated by that Vary field value match those fields in the original request."
Practical: Vary: Accept-Encoding, Authorization means "separate cache entries per (Accept-Encoding, Authorization) combination." Missing Vary: Authorization is the canonical cross-tenant cache leak per cross-tenant-data-leak-tests Test 10.
ETag + If-None-Match revalidation
Per RFC 9111 §4.3.1: "Another validator is the entity tag given in an ETag field. One or more entity tags can be used in an If-None-Match header field for response validation."
Pattern: server returns ETag: "abc123"; client sends If-None-Match: "abc123"; server returns 304 Not Modified or 200 OK with new ETag. Bandwidth-efficient but doesn't help latency (still a round-trip).
Sources
stale-while-revalidate and stale-if-error (RFC 5861)
View source (opens in new window)stale-while-revalidate and stale-if-error (RFC 5861)
stale-while-revalidate (SWR) and stale-if-error (SIE) are Cache-Control extensions defined in RFC 5861 (opens in new window), widely implemented by browsers, CDNs (Cloudflare, Fastly, CloudFront), and reverse proxies (Varnish via grace).
| Directive | When stale-serve happens | Revalidation |
|---|---|---|
stale-while-revalidate=N | Up to N seconds after max-age expires | Background async; client sees stale |
stale-if-error=N | Origin returns 5xx, up to N seconds after max-age | Client sees stale instead of the 5xx |
stale-while-revalidate lifecycle
Per RFC 5861 §3 (opens in new window): "caches MAY serve the response in which it appears after it becomes stale, up to the indicated number of seconds." Syntax: Cache-Control: max-age=60, stale-while-revalidate=300.
Failed-revalidation behaviour differs per vendor (Cloudflare keeps serving stale until the window expires; Fastly surfaces 5xx sooner; Varnish is VCL-configurable) - test the actual vendor.
stale-if-error
Per RFC 5861 §4 (opens in new window): a stale response "MAY be used to satisfy the request, regardless of other freshness information" on origin 500/502/503/504. Composition:
Cache-Control: max-age=60, stale-while-revalidate=300, stale-if-error=864001-minute freshness, 5-minute background-refresh grace, 1-day serve-stale grace if the origin is down.
Interaction with must-revalidate
Per RFC 9111, must-revalidate forbids serving stale after expiry. It and SWR are mutually exclusive in spirit; most caches honour the strictest (must-revalidate wins). For SWR / SIE to work, don't add must-revalidate.
Per-vendor support
| Cache | SWR | SIE | Caveat |
|---|---|---|---|
| Cloudflare | Yes | Yes | Honours response + request directives |
| Fastly | Yes (Surrogate-Control or Cache-Control) | Yes | Stale-on-error more aggressive |
| CloudFront | Yes (since 2022) | Yes | SIE needs origin error caching policy |
| Varnish | grace in VCL | stale-if-error | See varnish-test-vtc-syntax |
| nginx | proxy_cache_use_stale updating | ... error timeout | Different keyword |
| Browsers | Yes | Yes | Per-tab behaviour varies; test |
| Service Workers | Manual (Workbox SWR strategy) | n/a | Code-level implementation |
Testable behaviours
| Behaviour | Test |
|---|---|
| SWR serves stale within window | max-age=1, SWR=300; wait 5s; request → stale + async revalidate |
| SWR triggers exactly one revalidation | Origin sees one revalidate after the stale response returned |
| SWR window enforced | Wait > max-age + SWR; next request blocks on origin |
| SIE serves stale on 5xx | Origin down; request within SIE window → 200 with stale data |
| SIE window enforced | Origin down beyond window → user sees 5xx |
| must-revalidate wins over SWR | Both set → no stale served |
| Stampede mitigation under load | N=1000 concurrent at t=max-age+1s → origin sees 1-2 revalidates |
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
must-revalidate, stale-while-revalidate=300 | Contradictory; SWR silently ignored | Drop must-revalidate |
SWR on private user data without private | Stale exposure risks | Pair with private deliberately |
| SWR=0 | No grace; equivalent to omitting | Use ≥30s |
| SWR window >> max-age (×10+) | Stale for most of the lifetime | Keep proportionate |
| SIE without an origin-5xx alarm | "Site looks fine" while origin is down for days | Pair SIE with monitoring |
Limitations
References
Cache-stampede mitigation families
View source (opens in new window)Cache-stampede mitigation families
The three families named in stampede.md (opens in new window), in full, per Wikipedia's cache-stampede article. That file summarises each in one line and keeps the XFetch formula; this file carries the implementations, drawbacks, and the XFetch variable table.
1. Locking
Upon cache miss, processes attempt to acquire a lock for that key. Only the lock holder recomputes; others either wait, return "not found," or use a stale value.
def get(key):
val = cache.get(key)
if val is not None and not val.stale:
return val
if cache.acquire_lock(key, ttl=30):
try:
val = recompute(key)
cache.set(key, val, ttl=300)
return val
finally:
cache.release_lock(key)
else:
# Another process is recomputing; serve stale or wait
return val or wait_then_get(key)Drawbacks per Wikipedia: "complex implementation handling edge cases like process failures and race conditions." Lock holder crashing → cache empty for the lock TTL.
Mitigation: short-TTL locks with periodic refresh while recomputing.
2. External recomputation
A separate process recomputes the cache periodically or near expiry, decoupled from the request path. Per Wikipedia: "triggered when values approach expiration, periodically, or on cache miss."
# Cron / scheduled job
def refresh_hot_keys():
for key in HOT_KEYS:
val = recompute(key)
cache.set(key, val, ttl=600)When it fits: static cache keys ("homepage data," "top-10 products"). Hot keys are knowable in advance. The recompute schedule overlaps the cache TTL.
Drawback: doesn't help with unknown / user-specific hot keys; needs separate infrastructure.
3. Probabilistic early expiration (XFetch)
Each requester independently decides - with rising probability as the value ages - to refresh before formal expiry. Per Wikipedia, the canonical formula:
if (!value || (time() - delta * beta * log(rand(0,1))) >= expiry)
recompute_and_cache(key)
else
return valueWhere:
| Variable | Meaning |
|---|---|
delta | Time to recompute the value (scales the probability distribution) |
beta | Tuning parameter (default 1; >1 favours earlier refresh) |
log(rand(0,1)) | Always negative; magnitude controls the early-refresh probability |
time() | Wall-clock or monotonic time |
expiry | Absolute expiry time stored alongside the value |
The "exponential distribution" of refresh decisions means most requesters use the cached value; only a few do early refresh. Per Wikipedia: "setting beta=1 works well in practice."
Implementation:
import math, random, time
def get_xfetch(key):
entry = cache.get(key) # contains {value, expiry, delta}
if not entry:
val, delta = measure_recompute(key)
expiry = time.time() + 300
cache.set(key, {"value": val, "expiry": expiry, "delta": delta}, ttl=300)
return val
now = time.time()
rand = max(random.random(), 1e-10)
if now - entry["delta"] * 1.0 * math.log(rand) >= entry["expiry"]:
# Early refresh
val, delta = measure_recompute(key)
expiry = now + 300
cache.set(key, {"value": val, "expiry": expiry, "delta": delta}, ttl=300)
return val
return entry["value"]The delta (recompute cost) is measured during refresh and stored. Expensive-to-recompute values get earlier refresh attempts.
Cache stampede (thundering herd) - phenomena and mitigations
View source (opens in new window)Cache stampede (thundering herd) - phenomena and mitigations
A cache stampede ("dog-piling") occurs when a cached value expires under high load - many requesters simultaneously detect the miss, all recompute, all write back. Per en.wikipedia.org/wiki/Cache_stampede (opens in new window), the pathological state is "congestion collapse, preventing the resource from being recached and maintaining zero cache hit rates."
Symptoms in production
| Signal | Interpretation |
|---|---|
| DB / upstream latency spikes at cache-key TTL boundaries | Stampede on key expiry |
| Cache hit rate drops near zero, recovers slowly | Congestion collapse |
| Load spikes synchronised with cron / scheduled jobs | Multiple processes invalidating + recomputing |
| Recompute-cost-vs-traffic ratio > 0.1 | Hot key - stampede risk |
The three mitigation families
Per the Wikipedia article; full code, drawbacks, and the XFetch variable table are in stampede-mitigations.md (opens in new window).
if (!value || (time() - delta * beta * log(rand(0,1))) >= expiry)
recompute_and_cache(key)
else
return valuePer Wikipedia, "setting beta=1 works well in practice." Measure delta (recompute cost) during refresh and store it beside value and expiry.
Choosing and combining
Choose by key knowability: XFetch for unknown / user-specific keys, external recompute for known-hot keys, locking as a backstop. The strongest setups layer them:
| Layer | Strategy |
|---|---|
| Cache backend | TTL + stale-while-revalidate (stale-while-revalidate.md (opens in new window)) |
| App logic | XFetch on read for hot keys |
| Operations | External recompute for known-hot keys |
| Safety net | Distributed lock (Redis SET NX EX) |
Worked example
A homepage "top-10 products" aggregate under one key with ttl=300 expires at the traffic peak; ~1,200 concurrent misses hit the database and the hit rate collapses for ~40s. The key is known and hot, so external recomputation fits: a cron refreshes it every 240s, with XFetch on read as a backstop for a missed cron run. The load test below then asserts the upstream sees <=5 recomputes, down from ~1,000.
Testable behaviours
| Behaviour | Test |
|---|---|
| Lock holds under contention | N concurrent gets on a missing key → 1 recompute, N-1 wait/stale |
| XFetch probability rises near expiry | Statistical: fraction refreshing early within target band |
| External recompute fires before TTL | Write source → wait → assert cache reflects new value |
| Stampede absent under load | N=1000 concurrent on cold key → upstream sees 1-5 recomputes |
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
| No mitigation at all | Stampede inevitable under traffic | Pick at least one family |
| Lock without TTL | Lock-holder crash → deadlock | TTL on locks |
XFetch with very high beta (10+) | Everyone refreshes constantly | beta=1; tune via load test |
| External recompute without monitoring | Failed cron → stampedes return silently | Alarm on cache-miss-rate spike |
Hot key with must-revalidate | Forced revalidation = forced stampede at TTL | SWR or grace mode |
| Mitigation tested only at low load | Passes at 10 RPS, fails at 1000 | Production-equivalent concurrency |
Limitations
References
Related skills
cache-key-discriminator-audit
Audits whether a cache key carries every discriminator the cached response actually depends on, so two requests that must not share a slot cannot collide. Ranks identity discriminators (tenant, user, authorization scope, plan tier) above presentation ones (locale, region, currency, feature flag), maps each missing discriminator to the data-exposure or wrong-content consequence it causes, classifies each key-and-value pair into a critical / high / medium severity band (including the Python lru_cache-on-an-instance-method trap), and writes the fix as a namespaced key builder plus the matching HTTP Vary header. Use when a cache key is being designed or changed, when a per-user or per-tenant response is about to be stored in a shared cache or CDN, or when investigating a report that one user or tenant saw another's data.
cdn-cache-purge-tests
Wraps CDN cache-purge testing patterns for Cloudflare (POST /zones/{zone_id}/purge_cache, single-file / everything / cache-tags / hostname / prefix), Fastly (POST purge-by-key / purge-all, surrogate-keys via Surrogate-Key header), and CloudFront (CreateInvalidation API + paths). Covers end-to-end test patterns (write origin → trigger purge → assert edge serves fresh), purge-propagation delay testing (typically 1-30s globally), surrogate-key + cache-tag patterns for group-purge, and Cache-Status header verification (cf-cache-status: HIT/MISS/BYPASS). Also owns the client tier: browser-side Cache-Control verification with Playwright (served-from-cache via CDP, ETag 304 round-trips, Workbox service-worker strategies, reload semantics) in references/browser-cache-control.md. Use when designing or auditing CDN cache-invalidation workflows or browser-tier caching behaviour in E2E tests.
redis-cache-tests
Wraps Redis cache testing: EXPIRE / PEXPIRE / TTL verification (Redis 7+ NX/XX/GT/LT flags), cache-aside write-then-invalidate (write source → DEL key → assert fresh read), eviction under memory pressure (maxmemory + allkeys-lru), pub/sub invalidation across nodes, and tenant key-namespacing. Use when Redis is the app's primary cache. For the CDN/browser HTTP tier use cdn-cache-purge-tests; a runnable test, not the cache-coherence-patterns-reference catalog (which owns the stampede + stale-while-revalidate references).
varnish-test-vtc-syntax
Wraps the varnishtest CLI + VTC (Varnish Test Case) syntax for testing VCL configurations. Covers the VTC test-file format (varnishtest scripts with server { ... } + client { ... } + varnish v1 -vcl+backend { ... } blocks), the grace-mode + saint-mode behaviours (stale-while-revalidate + stale-if-error equivalents in VCL), the PURGE method handler pattern (vcl_purge subroutine + ACL guards), and surrogate-key invalidation via xkey vmod. Use when authoring or testing Varnish-based caching layers.