Testland
Browse all skills & agents

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-reference
View source

cache-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

  • Designing the cache tiers for a new product / endpoint.
  • Auditing an existing cache for coherence bugs (stale reads after writes, cross-tenant cache leaks, layered TTLs that fight each other).
  • PR review of changes to cache headers, Vary, or invalidation triggers.
  • Investigating "users see stale data" reports.

How to use this reference

  1. Locate the tier(s) the value lives in from the five-tier stack - each tier (browser, CDN, reverse proxy, application, data store) has its own TTL and invalidation mechanism.
  2. Choose the write pattern for the application tier from the cache-writing patterns table (cache-aside, write-through, write-back, write-around, refresh-ahead) based on the read/write mix and how much consistency you need.
  3. Choose the invalidation strategy from the invalidation strategies table (TTL-only, event-driven purge, surrogate keys, version-tagged URLs, soft purge) based on the staleness window you can tolerate.
  4. Set the contract - design the Cache-Control directives, Vary key, and ETag validators per references/rfc-9111-http-caching-directives.md.
  5. Write the per-tier coherence test from the cross-tier problems and test-surface catalog in references/cross-tier-coherence-and-test-surface.md, then re-check the design against the Anti-patterns table below.

The five-tier stack

TierWhereCommon TTLInvalidation
BrowserCache-Control: privateminutes-hoursTTL only (or Service Worker code)
CDNCloudflare / Fastly / CloudFront / Akamaiseconds-daysPurge API or surrogate-key tag
Reverse proxyVarnish, nginxseconds-hoursVCL purge / nginx cache_purge
ApplicationRedis / Memcached / in-processseconds-minutesDirect delete / pub-sub broadcast
Data storePostgres query cache, RDS read replicassecondsReplication-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):

PatternFlowWhen
Cache-aside (lazy load)Read miss → read source → populate → return; Write → invalidate cacheRead-heavy, eventual consistency OK
Write-throughWrite → write source → write cache (synchronous)Strong consistency, latency tolerable
Write-backWrite → write cache → async write to sourceBurst writes; data-loss risk on cache crash
Write-aroundWrite → write source (skip cache); reads do cache-asideWrite-heavy with rare re-reads
Refresh-aheadBackground refresh before TTL expiresPredictable read patterns; hot keys

Invalidation strategies

StrategyMechanismTrade-off
TTL-onlyJust let it expireSimple; possibly-stale window = TTL
Event-driven purgeSource-of-truth update fires a deleteCoupling; firehose at high write rate
Surrogate keys (Fastly, Varnish)Tag responses; purge by tagGroup-invalidation; coordination cost
Version-tagged URLs/api/users?_v=42; new version = new keyImmutable cache; full deploy per change
Soft purgeMark stale, keep serving until refreshUsed 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.

  1. Tiers. The response flows browser → CDN → application (Redis) → data store. Because the payload is per-user, it must not sit in a shared cache: the browser tier gets Cache-Control: private and the CDN is bypassed for this route (or keyed per tenant), not left on the shared edge.
  2. Write pattern. Reads dominate and eventual consistency after a profile edit is acceptable, so the application tier uses cache-aside: a read miss loads from the data store and populates Redis; a write invalidates the tenant's Redis key.
  3. Invalidation. A profile edit is an urgent update, so TTL-only is not enough - pair the TTL with event-driven purge so the write fires a delete of the tenant's cache key immediately.
  4. Contract. Set Vary: Authorization so each tenant gets a separate cache entry (the fix for the cross-tenant leak in the Anti-patterns table), and add a content-hash ETag so an unchanged reload returns 304 Not Modified instead of the full body.

Coherence test (the browser-tier "write → reload → see old" case):

  • Arrange: tenant A loads /api/users; the response is cached.
  • Act: tenant A edits a user - which must fire the Redis purge - then reloads the page.
  • Assert (staleness): the reload shows the edited value, not the pre-write state. A failure here means the write path skipped the invalidate - the "cache-aside without write-then-invalidate" anti-pattern.
  • Assert (isolation): a request from tenant B with a different Authorization header never returns tenant A's cached rows, proving the Vary: Authorization split holds.

Anti-patterns

Anti-patternWhy it failsFix
Cache-Control: public on per-user dataShared cache leaks dataUse private for user-specific
Missing Vary: AuthorizationCross-tenant leakAdd to Vary or set private
s-maxage longer than session lifetimeLogged-out users see another user's dataMatch TTL to security window
TTL but no purgeStale-window = TTL even for urgent updatesImplement purge API + use surrogate keys
ETag generated per-request from now()Defeats the validationStable ETag from content hash
no-cache instead of no-store for sensitive dataBrowser still stores; just revalidatesno-store, no-cache, must-revalidate, private
Browser TTL = CDN TTL = origin TTLMulti-tier amplifies staleness instead of layering itOrigin lowest, CDN longer, browser shortest
Cache-aside without write-then-invalidateReads see pre-write state for TTL windowAlways invalidate on write
Vary: *Disables shared cache entirelyUse specific headers
Single Cache-Control for HTML + JSON + assetsOne-size doesn't fit; HTML often short, assets longPer-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

  • RFC 9111 governs HTTP caches only. Application-tier caches (Redis) use their own semantics; coherence is application- enforced.
  • Doesn't specify replication. Read replicas, multi-region CDN have their own coherence layer.
  • No global invalidation. Cross-tier purge requires coordination; no built-in protocol.
  • Cache-Control parsing has implementation drift. Some CDNs ignore directives they don't recognise; verify per vendor.

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.

ProblemWhereDetection
Browser caches stale page after server purgeBrowser ignores must-revalidate, or no must-revalidateE2E test: write → reload → see old
CDN serves stale after origin updatePurge didn't propagate or s-maxage too longE2E: write → purge → read at CDN edge
Different Vary at browser vs CDNCDN strips headers; cache keys divergeHeader-comparison test
Layered TTL inversions-maxage < max-age → CDN refreshes more often than browser; browser eventually outpaces CDNAudit the TTL stack
Vary: Cookie without normalised cookiesTracker cookies fragment cache; near-zero hit rateInspect Vary; normalise
Tenant-scoped data with shared VaryCross-tenant leak per cross-tenant-data-leak-testsAdd 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.

TierTest categories
BrowserCache-Control respected (max-age, no-cache, must-revalidate); ETag round-trip; Vary honoured
CDNEdge hit/miss vs origin; purge API works end-to-end; s-maxage overrides max-age
Reverse proxyVCL purge (varnish-test-vtc-syntax); grace-mode behaviour
ApplicationCache-aside write-then-invalidate; key collisions
Data storeReplication 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)

DirectiveRFC refMeaning
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."
immutableRFC 8246Response 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).

DirectiveWhen stale-serve happensRevalidation
stale-while-revalidate=NUp to N seconds after max-age expiresBackground async; client sees stale
stale-if-error=NOrigin returns 5xx, up to N seconds after max-ageClient 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.

  1. t < max-age → fresh cache hit.
  2. max-age < t < max-age + SWR → stale served and one async revalidation fires - this is the stampede-mitigation property: only the first request revalidates, the herd coasts on stale (stampede.md (opens in new window)).
  3. Revalidation succeeds → cache refreshed.
  4. t > max-age + SWR → truly stale; next request blocks on origin.

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=86400

1-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

CacheSWRSIECaveat
CloudflareYesYesHonours response + request directives
FastlyYes (Surrogate-Control or Cache-Control)YesStale-on-error more aggressive
CloudFrontYes (since 2022)YesSIE needs origin error caching policy
Varnishgrace in VCLstale-if-errorSee varnish-test-vtc-syntax
nginxproxy_cache_use_stale updating... error timeoutDifferent keyword
BrowsersYesYesPer-tab behaviour varies; test
Service WorkersManual (Workbox SWR strategy)n/aCode-level implementation

Testable behaviours

BehaviourTest
SWR serves stale within windowmax-age=1, SWR=300; wait 5s; request → stale + async revalidate
SWR triggers exactly one revalidationOrigin sees one revalidate after the stale response returned
SWR window enforcedWait > max-age + SWR; next request blocks on origin
SIE serves stale on 5xxOrigin down; request within SIE window → 200 with stale data
SIE window enforcedOrigin down beyond window → user sees 5xx
must-revalidate wins over SWRBoth set → no stale served
Stampede mitigation under loadN=1000 concurrent at t=max-age+1s → origin sees 1-2 revalidates

Anti-patterns

Anti-patternWhy it failsFix
must-revalidate, stale-while-revalidate=300Contradictory; SWR silently ignoredDrop must-revalidate
SWR on private user data without privateStale exposure risksPair with private deliberately
SWR=0No grace; equivalent to omittingUse ≥30s
SWR window >> max-age (×10+)Stale for most of the lifetimeKeep proportionate
SIE without an origin-5xx alarm"Site looks fine" while origin is down for daysPair SIE with monitoring

Limitations

  • Async revalidation is best-effort; the stale entry can be evicted under memory pressure → blocking fetch.
  • Cold cache always blocks - SWR needs a previously cached response.
  • no-store overrides everything.
  • Staleness is invisible to users unless a Warning header survives (many CDNs strip it).

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 value

Where:

VariableMeaning
deltaTime to recompute the value (scales the probability distribution)
betaTuning 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
expiryAbsolute 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

SignalInterpretation
DB / upstream latency spikes at cache-key TTL boundariesStampede on key expiry
Cache hit rate drops near zero, recovers slowlyCongestion collapse
Load spikes synchronised with cron / scheduled jobsMultiple processes invalidating + recomputing
Recompute-cost-vs-traffic ratio > 0.1Hot 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).

  1. Locking - on miss, one process acquires a per-key lock and recomputes; others wait, return "not found," or serve stale. Risk: lock-holder crash leaves the cache empty for the lock TTL.
  2. External recomputation - a cron / near-expiry job refreshes known-hot keys off the request path. Doesn't help unknown or user-specific hot keys.
  3. Probabilistic early expiration (XFetch) - each reader refreshes early with rising probability as the value ages:
if (!value || (time() - delta * beta * log(rand(0,1))) >= expiry)
  recompute_and_cache(key)
else
  return value

Per 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:

LayerStrategy
Cache backendTTL + stale-while-revalidate (stale-while-revalidate.md (opens in new window))
App logicXFetch on read for hot keys
OperationsExternal recompute for known-hot keys
Safety netDistributed 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

BehaviourTest
Lock holds under contentionN concurrent gets on a missing key → 1 recompute, N-1 wait/stale
XFetch probability rises near expiryStatistical: fraction refreshing early within target band
External recompute fires before TTLWrite source → wait → assert cache reflects new value
Stampede absent under loadN=1000 concurrent on cold key → upstream sees 1-5 recomputes

Anti-patterns

Anti-patternWhy it failsFix
No mitigation at allStampede inevitable under trafficPick at least one family
Lock without TTLLock-holder crash → deadlockTTL on locks
XFetch with very high beta (10+)Everyone refreshes constantlybeta=1; tune via load test
External recompute without monitoringFailed cron → stampedes return silentlyAlarm on cache-miss-rate spike
Hot key with must-revalidateForced revalidation = forced stampede at TTLSWR or grace mode
Mitigation tested only at low loadPasses at 10 RPS, fails at 1000Production-equivalent concurrency

Limitations

  • XFetch assumes exponentially distributed recompute cost; bimodal workloads should tune delta to p95, not mean.
  • Mitigations work per cache node; geo-distributed setups need per-region coordination, and TTL skew across nodes yields many node-local stampedes.

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.