Caching is how you go from a database that handles 500 queries per second to a system that handles 50,000 without buying more hardware. Done wrong, it's also how you serve stale prices, wrong user data, and phantom inventory. The pattern you pick and the TTL rules you set determine which outcome you get.
What changed in 2026
- Redis 8 introduced a native tiered storage (RAM + NVMe), making very large caches economic — datasets that previously needed a Memcached fleet now fit in a single Redis cluster with persistence.
- Valkey (the Redis community fork) reached production parity and is now the default in several cloud providers' managed cache offerings, but the API is identical for application code.
- CDN providers (Cloudflare, Fastly, Vercel) added stale-while-revalidate at the edge as a first-class policy, eliminating the origin spike at TTL expiry for public APIs.
- HTTP Cache-Control
no-store vs no-cache confusion remains the top cause of unintended CDN bypass — the HTTP spec hasn't changed, but awareness has improved.
The four main patterns
| Pattern |
Who populates the cache |
Consistency |
Write cost |
Miss penalty |
| Cache-aside |
Application on read miss |
Eventual (TTL) |
Low |
One extra read |
| Read-through |
Cache layer |
Eventual (TTL) |
Low |
None (transparent) |
| Write-through |
Cache layer on write |
Strong |
Double write |
None |
| Write-behind (async) |
Cache layer, async flush |
Weak |
Low |
None |
Cache-aside (lazy loading)
The application checks the cache first. On a miss, it reads from the database, populates the cache, and returns the value. On a hit, it returns directly from cache.
def get_user(user_id: int) -> dict:
cache_key = f"user:{user_id}"
cached = redis.get(cache_key)
if cached:
return json.loads(cached)
user = db.query("SELECT * FROM users WHERE id = %s", user_id)
redis.setex(cache_key, 300, json.dumps(user)) # TTL: 5 minutes
return user
Pros: Simple, resilient (cache failure doesn't break reads), only caches what's actually used.
Cons: Cold start causes miss storms; stale for up to TTL after a write.
Invalidation: on mutation, delete the key explicitly (redis.delete(cache_key)) rather than writing through — simpler and avoids race conditions.
Write-through
Every write goes to both the database and the cache atomically (or near-atomically). Reads always hit a warm cache.
def update_user(user_id: int, data: dict):
db.execute("UPDATE users SET ... WHERE id = %s", user_id)
cache_key = f"user:{user_id}"
redis.setex(cache_key, 300, json.dumps(data))
Use write-through when cache misses are very expensive (e.g., expensive aggregation queries) and write volume is low. Avoid it for high-write tables — every write takes twice as long.
CDN and HTTP caching
For public read-heavy APIs, HTTP headers are your highest-leverage caching layer.
Cache-Control: public, max-age=60, stale-while-revalidate=30
Vary: Accept-Encoding
ETag: "abc123"
stale-while-revalidate=30 tells the CDN to serve the stale cached response for 30 seconds while fetching a fresh copy in the background — zero latency penalty at TTL expiry.
For authenticated APIs: Cache-Control: private, max-age=0, no-cache — never cache user-specific responses at a shared CDN layer.
Cache eviction policies
| Policy |
Best for |
| LRU (Least Recently Used) |
General purpose |
| LFU (Least Frequently Used) |
Skewed access patterns (hot keys) |
| TTL-only |
Time-sensitive data (prices, tokens) |
No eviction (Redis noeviction) |
Session stores where overflow must fail loudly |
Redis default is LRU. Set maxmemory-policy explicitly — never leave it at the default noeviction in a cache (vs session store) use case.
How to pick
- Is the data public and mostly static? → CDN/HTTP caching first, application cache second.
- Is the data user-specific or dynamic? → Cache-aside with a short TTL.
- Are cache misses catastrophically slow (cold query > 500ms)? → Write-through to keep it warm.
- Is write volume high (>1k writes/sec per key)? → Cache-aside with explicit invalidation; write-through doubles your write latency.
- Can you tolerate brief staleness? → Longer TTLs, stale-while-revalidate for public data.
Common mistakes
No TTL. Cache entries without expiry are correct until they aren't, and you won't notice until users report stale data. Every SET in your application cache code must have a TTL.
Caching the absence of data. If db.get(id) returns None (not found), don't skip caching — cache the null result with a short TTL (30–60s) to prevent a cache miss thundering herd on a hot missing key.
Stampede on expiry. When a popular key expires, dozens of requests all miss simultaneously, hit the DB, and all try to write back. Use a probabilistic early expiration strategy or a distributed lock around the miss path.
Using the same cache for sessions and application data with different eviction policies — separate Redis instances (or at least separate databases) for different concerns.
Over-caching. Caching data that is already fast to compute or rarely accessed wastes memory and adds an invalidation surface. Profile before caching.
What to skip
- Caching in the ORM layer without knowing it — many ORMs have optional first-level caches; make sure you understand their invalidation semantics before trusting them.
- Write-behind for financial or inventory data — async flush means data can be lost on crash. Use write-through or skip caching for data that must be durable.
- Local in-process caches across multiple pods — they diverge immediately. Use Redis/Valkey for any cache that must be consistent across instances.
FAQ
How do I handle cache invalidation across microservices?
Publish invalidation events (e.g., via a message queue or event stream) when data changes, and have each service subscribe and delete its own cache keys. See Event-driven architecture in 2026.
What TTL should I start with?
60 seconds is a safe default for dynamic user-facing data. Tune up (5–30 minutes) for slow-changing reference data. Use TTL=0 (no cache) for transactional data that must always be fresh.
Should I use Redis or Memcached in 2026?
Redis (or Valkey) for almost everything — persistence, rich data types, and cluster mode make it far more capable. Memcached retains a niche only for teams that need pure ultra-low-latency key-value with no persistence.
How do I test cache behaviour in CI?
Use Fakeredis or an embedded Redis (Testcontainers) in tests. Write explicit tests for cache hits, misses, TTL expiry paths, and the miss stampede guard.
Where to go next
See API rate limiting in 2026 for reducing load so fewer requests reach the origin, and Database indexing explained in 2026 for making the underlying queries faster when cache misses do happen.