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.
- name
cache-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.
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 class | Example | Budget | Typical strategy |
|---|---|---|---|
| Static reference | Country list, feature catalog | Hours to days | Long TTL + versioned key on deploy |
| Shared, slowly changing | Product details, prices shown in lists | 30 s to 5 min | TTL + delete on write |
| User-visible after own write | Profile, settings, cart | Read-your-writes | Delete on write + versioned key, or bypass for the writer |
| Money, inventory, permissions | Balance, stock at checkout, roles | Zero for decisions | Do not cache the decision path; cache only for display |
| Expensive aggregates | Dashboards, counts | Minutes | TTL with background refresh |
Write the budget next to the cache code. “As fresh as possible” is not a budget.
3. Layers and keys
| Layer | Latency | Invalidation reach | Use for |
|---|---|---|---|
| CDN / HTTP cache | Edge | Purge APIs, TTL | Public, non-personal responses (http-caching) |
| In-process (L1) | Nanoseconds | Only this instance | Tiny, very hot, tolerant data; short TTL (seconds) |
| Distributed (Redis/Valkey, Memcached) (L2) | Sub-millisecond to 1 ms | All instances | Shared application cache |
| Database-side (materialized view) | Query time | Refresh schedule | Aggregates |
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
| Strategy | Staleness | Complexity | Choose when |
|---|---|---|---|
| TTL only | Up to TTL | Lowest | Budget is at least the TTL; writes are rare or unknown |
| Cache-aside + delete on write | Race window plus replication lag | Low | The writer is your code and you know which keys a write affects |
| Write-through (update cache with new value on write) | Low | Medium | Single writer per key; otherwise concurrent writers race |
| Event-driven (CDC or domain events invalidate) | Event lag (ms to s) | Medium to high | Many writers, other services write the data, or keys are hard to enumerate at write time |
| Versioned / generation keys | None for readers of the new version | Low | Invalidating a whole group (all of a tenant’s catalog): bump catalog_gen:tenant:42, old keys expire by TTL |
| Tag-based purge | Purge latency | Medium | CDNs 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 keyafter 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:
- Short TTL on the affected class so the window is bounded (always do this).
- Versioned set: store
{version, value}and set only if the cached version is older (Lua script orWATCH), using a row version orupdated_atfrom the database read. - Leases: on a miss, the cache hands the reader a lease token; a delete invalidates
outstanding leases, so the late
SETis rejected (Memcached-style leases; emulate in Redis with a token key deleted by the writer). - 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.
| Technique | How | Use when |
|---|---|---|
| TTL jitter | TTL 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 promise | Always for hot keys; per-process scope |
| Distributed lock on recompute | SET lock:key token NX PX 5000; others serve stale or wait briefly | Expensive recompute, many instances |
| Stale-while-revalidate | Store soft and hard expiry; after soft, serve stale and refresh in background | Budget tolerates brief staleness |
| Probabilistic early refresh (XFetch) | Refresh before expiry with probability rising near expiry | Very 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
FLUSHALLon 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 patternto find entries to delete blocks Redis; use versioned keys orSCAN.- Eviction policy
noevictionturns a full cache into write errors; caches usually wantallkeys-lruorallkeys-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.