Architecture No. 313

Cache Invalidation skill for Claude Code

Staleness budgets, closed races, no stampedes. Claude sets a staleness budget for each kind of cached data and picks invalidation from it: TTL only, delete after commit, versioned keys, or change-data-capture events. It closes the reader-writer race that leaves stale values in place until expiry, protects hot keys with single flight, early refresh and stale-while-revalidate, and adds hit ratio and staleness sampling so freshness is measured, not hoped for.

  • namecache-invalidation
  • size198 lines · ≈2,400 tokens
  • files1
  • licenseMIT
  • updated

What Claude sees before loading it

This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.

description: Designs application cache architectures and their invalidation - what to cache at which layer, per-data staleness budgets, TTL-only versus delete-on-write versus event-driven invalidation from change data capture, versioned keys and tag purges, the read-write race that leaves stale entries forever, stampede protection with request coalescing, early probabilistic refresh and stale-while-revalidate, two-level in-process and Redis caches, and cache metrics. Use when adding a cache, users see stale data after updates, a cache expiry causes a database spike, or someone asks how to keep several caches consistent.

Try asking

  • Users still see their old profile photo after updating it. Fix our cache logic
  • Every hour our database spikes when the product cache expires. Stop the stampede
  • Design caching for our catalog service with clear freshness guarantees

Use it when

  • Adding a cache in front of a database or service
  • Users see stale data after their own writes
  • Cache expiry causes database load spikes
  • Several caches or instances must stay consistent

Skip it when

  • You need Redis commands, data structures or memory tuning — use redis-patterns
  • The cache is browser or CDN headers — use http-caching

How to check the result

A concurrent reader-writer loop never leaves the old value cached beyond the budget, expiring a hot key under load keeps database QPS flat, and hit ratio and stale-read samples are on a dashboard.

The common mistake it prevents

Deleting the cache key inside the database transaction or setting it from the writer, so a concurrent reader repopulates the old value and it stays until TTL.

~/.claude/skills/cache-invalidation/SKILL.md

--- name: cache-invalidation description: Designs application cache architectures and their invalidation - what to cache at which layer, per-data staleness budgets, TTL-only versus… license: MIT ---

Done well means: every cached data class has a written staleness budget, the invalidation mechanism is chosen from that budget, writes cannot leave a stale entry that outlives its TTL, a hot key expiring never sends a thundering herd to the database, and hit ratio and staleness are measured.

Workflow

- [ ] 1. Justify the cache: measure the load or latency it removes
- [ ] 2. Classify data and set a staleness budget per class
- [ ] 3. Choose the layer(s) and the key design
- [ ] 4. Choose the invalidation strategy per class
- [ ] 5. Close the read-write race
- [ ] 6. Add stampede protection for hot keys
- [ ] 7. Instrument, then test invalidation under concurrency

1. Justify

Caches add a consistency problem. Before adding one: is the query indexed, can the result be computed once and stored (materialized view, denormalized column), or does HTTP caching solve it at the edge? Cache when reads dominate (10:1 or more), recomputation is expensive, and some staleness is acceptable.

2. Staleness budgets

Data classExampleBudgetTypical strategy
Static referenceCountry list, feature catalogHours to daysLong TTL + versioned key on deploy
Shared, slowly changingProduct details, prices shown in lists30 s to 5 minTTL + delete on write
User-visible after own writeProfile, settings, cartRead-your-writesDelete on write + versioned key, or bypass for the writer
Money, inventory, permissionsBalance, stock at checkout, rolesZero for decisionsDo not cache the decision path; cache only for display
Expensive aggregatesDashboards, countsMinutesTTL with background refresh

Write the budget next to the cache code. “As fresh as possible” is not a budget.

3. Layers and keys

LayerLatencyInvalidation reachUse for
CDN / HTTP cacheEdgePurge APIs, TTLPublic, non-personal responses (http-caching)
In-process (L1)NanosecondsOnly this instanceTiny, very hot, tolerant data; short TTL (seconds)
Distributed (Redis/Valkey, Memcached) (L2)Sub-millisecond to 1 msAll instancesShared application cache
Database-side (materialized view)Query timeRefresh scheduleAggregates

Key rules:

  • Include everything that changes the value: v3:tenant:42:product:981:locale:de.
  • Include a schema version prefix (v3) so a deploy with a new value shape does not read old-shaped entries; bump it instead of flushing.
  • Never cache per-user or per-tenant data under a key without the user or tenant ID.
  • Bound value size (for example 100 KB); large values cause latency spikes and evictions.

4. Invalidation strategies

StrategyStalenessComplexityChoose when
TTL onlyUp to TTLLowestBudget is at least the TTL; writes are rare or unknown
Cache-aside + delete on writeRace window plus replication lagLowThe writer is your code and you know which keys a write affects
Write-through (update cache with new value on write)LowMediumSingle writer per key; otherwise concurrent writers race
Event-driven (CDC or domain events invalidate)Event lag (ms to s)Medium to highMany writers, other services write the data, or keys are hard to enumerate at write time
Versioned / generation keysNone for readers of the new versionLowInvalidating a whole group (all of a tenant’s catalog): bump catalog_gen:tenant:42, old keys expire by TTL
Tag-based purgePurge latencyMediumCDNs and caches that support surrogate keys

Defaults:

  • Always set a TTL, even with active invalidation. TTL is the safety net for every missed invalidation; pick the longest TTL the budget allows.
  • Delete, do not set, on write (DEL key after commit). Setting from the writer races with other writers and with readers.
  • Invalidate after the transaction commits, never before or inside it. Inside the transaction, a reader can repopulate the old value before the commit lands.
  • Event-driven invalidation from CDC (Debezium on the database log, or the outbox) catches writes from every service and from manual SQL; use it when writes come from more than one code path.

5. The read-write race

Cache-aside with delete-on-write can still leave a stale value until TTL:

Reader: cache miss -> reads row (old value) ......................... SET key old
Writer:                     UPDATE row -> COMMIT -> DEL key

The reader’s late SET writes the old value after the writer’s delete. Mitigations, in order of preference:

  1. Short TTL on the affected class so the window is bounded (always do this).
  2. Versioned set: store {version, value} and set only if the cached version is older (Lua script or WATCH), using a row version or updated_at from the database read.
  3. Leases: on a miss, the cache hands the reader a lease token; a delete invalidates outstanding leases, so the late SET is rejected (Memcached-style leases; emulate in Redis with a token key deleted by the writer).
  4. Delayed second delete: delete after commit and again after a delay longer than a typical read (for example 500 ms to 2 s via a delayed job). Cheap, probabilistic.

Read replicas widen the window: a reader that hits a lagging replica after the delete repopulates the old value. Read from the primary on cache misses for read-your-writes classes, or include replication lag in the budget.

6. Stampede protection

When a hot key expires, every concurrent request misses and hits the database.

TechniqueHowUse when
TTL jitterTTL x random(0.9, 1.1)Always, for keys created in bulk
Request coalescing (single flight)One loader per key per process; others await its promiseAlways for hot keys; per-process scope
Distributed lock on recomputeSET lock:key token NX PX 5000; others serve stale or wait brieflyExpensive recompute, many instances
Stale-while-revalidateStore soft and hard expiry; after soft, serve stale and refresh in backgroundBudget tolerates brief staleness
Probabilistic early refresh (XFetch)Refresh before expiry with probability rising near expiryVery hot keys; no locks
// Per-process single flight + stale-while-revalidate + XFetch-style early refresh
const inflight = new Map<string, Promise<unknown>>();

async function cached<T>(key: string, ttlMs: number, load: () => Promise<T>): Promise<T> {
  const entry = await redisGetJson<{ v: T; exp: number; delta: number }>(key);
  const now = Date.now();
  const beta = 1.0;
  const shouldRefresh =
    !entry || now - entry.delta * beta * Math.log(Math.random()) >= entry.exp;

  if (entry && !shouldRefresh) return entry.v;
  if (entry && now < entry.exp + ttlMs) {          // still within stale window: refresh async
    refresh(key, ttlMs, load).catch(logError);
    return entry.v;
  }
  return refresh(key, ttlMs, load);                // hard miss: callers share one load
}

function refresh<T>(key: string, ttlMs: number, load: () => Promise<T>): Promise<T> {
  let p = inflight.get(key) as Promise<T> | undefined;
  if (!p) {
    p = (async () => {
      const start = Date.now();
      const v = await load();
      const delta = Date.now() - start;            // recompute cost drives early refresh
      const jitter = ttlMs * (0.9 + Math.random() * 0.2);
      await redisSetJson(key, { v, exp: Date.now() + jitter, delta }, 2 * ttlMs);
      return v;
    })().finally(() => inflight.delete(key));
    inflight.set(key, p);
  }
  return p;
}

Negative caching: cache “not found” for a short TTL (for example 30 s) so lookups of missing IDs do not bypass the cache, and delete that entry when the row is created.

Two-level caches

  • L1 (in-process) TTL in seconds, L2 TTL per the budget.
  • Broadcast invalidations to all instances (Redis pub/sub channel or client-side caching with tracking) and still rely on the short L1 TTL, because pub/sub messages can be lost.
  • Never put per-user data in L1 on shared multi-tenant processes without bounding size.

7. Instrument and test

Metrics per cache class: hit ratio, miss load latency, evictions, key count and memory, invalidations sent, and stale reads detected (sample: compare cached value with source on 1 in 1,000 reads and count mismatches).

Tests:

  • Concurrency test for the race: run a slow reader and a writer concurrently in a loop; after both finish, the cache must not hold the old value beyond the budget.
  • Expire a hot key under load and confirm database QPS stays flat (single flight works).
  • Kill the cache: the app must degrade to direct reads with bounded concurrency, not fall over. Decide fail-open (read source) vs serve-stale per class.

Gotchas

  • FLUSHALL on a shared Redis to “fix staleness” drops every service’s cache at once and causes a stampede; it needs explicit user approval and a plan.
  • Caching ORM entities with lazy relations serializes half the database or fails on read. Cache plain DTOs.
  • Caching permission checks makes revocations lag; bound the TTL to what security accepts.
  • KEYS pattern to find entries to delete blocks Redis; use versioned keys or SCAN.
  • Eviction policy noeviction turns a full cache into write errors; caches usually want allkeys-lru or allkeys-lfu.

Output contract

| Data class | Key pattern | Layer | Budget | TTL | Invalidation | Race mitigation | Stampede protection |

Plus: failure mode when the cache is unavailable, and the metrics added.

About this skill

Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.

esc