System Design Lesson 5: Caching and CDNs Without Stale-Data Surprises
Caching is a controlled trade: serve a reusable answer faster and more cheaply, while accepting that a copy may become stale. The design work is not choosing Redis or a CDN. It is deciding which response may be reused, by whom, for how long, and what happens after the source of truth changes.
Prerequisites
Complete Lesson 4: data modeling, SQL, NoSQL, and storage choices. Bring the access patterns, ownership boundaries, and consistency requirements you identified there. The System Design learning track shows where caching fits in the broader sequence.
Learning outcome
By the end, you can place browser, CDN, and application caches deliberately; define a cache key and freshness contract; choose cache-aside or write-through behavior; and test misses, invalidation, and stampede behavior.
Start with a cacheability contract
For every proposed cache, answer four questions: What source owns the data? What exact request dimensions change the answer? How stale may it be? What event invalidates it? A public product image can often be cached at the edge for a long time. A personalized account page must not accidentally be shared between users. HTTP caching uses the request method and target URI at minimum; headers named by Vary can also affect reuse.
Use layers for different jobs
- Browser and CDN: cache public HTTP responses near the reader. Use explicit cache-control directives and versioned asset URLs.
- Application local cache: fastest for small, per-instance data that can be independently recreated. It disappears on restart and is not shared.
- Distributed cache: shared across application instances, useful for repeated reads and coordination, but adds network failure modes and operational ownership.
Worked example: cache-aside for a product
Cache-aside keeps the database as the source of truth. On a hit, return the cached representation. On a miss, read the source, populate the cache with a bounded TTL, then return it. After an update, invalidate the key or publish a versioned event that consumers use to evict it. A TTL alone is a staleness bound, not an invalidation strategy.
GET /products/42
key = "product:v3:42"
value = cache.get(key)
if value exists:
return value
value = database.query("select public fields for product 42")
cache.set(key, value, ttl=5 minutes, jitter=random(0..30 seconds))
return value
UPDATE /products/42
transactionally update the source of truth
cache.delete("product:v3:42")
return updated product
The version segment allows a safe key-shape change without interpreting old entries. Jitter avoids expiring a large cohort at the same instant. For expensive misses, use request coalescing or a short-lived lock carefully; never allow a failed lock path to make the database the unbounded fallback.
Prevent the classic failures
- Stampede: a popular key expires and many requests refill it at once. Use jitter, coalescing, and capacity testing.
- Cache penetration: repeated lookups for missing IDs reach the source. Consider a short negative cache only if its correctness and abuse profile are acceptable.
- Poisoning or leakage: never share a response whose key omits tenant, authorization, locale, or content negotiation dimensions.
- Inconsistent invalidation: use durable events/outbox delivery when another service must evict or refresh a projection.
Validation and review steps
- Write the cache key format and every input dimension beside the endpoint contract.
- Test a hit, miss, expired entry, source update, cache outage, and concurrent cold-key requests.
- Verify sensitive pages emit restrictive cache directives and cannot be retrieved by another identity.
- Measure hit ratio, origin load, cache latency, eviction rate, and stale-response age.
- Run a controlled invalidation drill and confirm users see the updated value within the documented bound.
Production guidance
Set timeouts and failure behavior for the cache just as you would for any dependency. A cache outage should degrade according to an explicit load-shedding plan, not silently overload the primary database. Monitor key cardinality and memory growth, restrict access to cache infrastructure, and avoid placing secrets or raw session material in broadly shared caches. A CDN configuration is production code: review headers, purge permissions, and origin bypass paths.
Next lesson
Lesson 6 covers queues, streams, asynchronous workflows, and rate limiting: how to decouple slow work, deliver messages safely, protect capacity, and make retrying clients predictable.