Caching vs. freshness
The tradeoff between serving data fast from a cache vs. serving data that reflects the latest state, covering TTL strategies, cache invalidation patterns, and how to decide which data can be cached and for how long.
TL;DR
| Data type | Cache aggressively | Serve fresh | Why |
|---|---|---|---|
| Account balance | Never cache during transactions | Always from primary DB | Showing stale balance during a transfer causes support tickets |
| Product catalog | Cache 1-5 min TTL, invalidate on edit | Not needed; changes are infrequent | 2M products, 100K views/min, 1000:1 read-to-write ratio |
| Social media like count | Cache 5-30s TTL | Not needed; approximate is fine | Nobody notices if a count is 5 seconds behind |
| News feed content | Cache 1-5 min with event-driven purge | After a user posts, show their own post immediately | Combination of caching + read-your-own-writes |
| Static assets (JS, CSS) | Immutable cache with content hashing | Never stale; hash changes on deploy | Cache-Control: immutable, max-age=31536000 |
Default instinct: cache everything, then carve out exceptions. Most data tolerates seconds to minutes of staleness. The exceptions (financial balances, inventory during checkout, real-time bidding) are the minority. Start by caching aggressively with TTL-based expiration, then add event-driven invalidation only for the data that truly needs it.
30-Second Explanation
A cache trades freshness for lower latency and less origin load. TTLs provide a simple upper bound on staleness; invalidation or versioned keys can make selected updates visible sooner but add coordination and failure modes. Cache data only when the product can tolerate the defined stale window, and keep correctness-critical decisions on an authoritative path. A cache should be an optimization with a safe miss and a clear refresh policy.
5-Minute Explanation
- Classify the data: authoritative decision, user-visible content, derived view, or immutable asset. Each category has a different freshness budget.
- Choose the policy: TTL, cache-aside, write-through, event-driven invalidation, versioned keys, or no cache. Document what happens when invalidation is delayed or lost.
- Protect the origin with request coalescing, jittered expiry, bounded retries, and negative-cache rules so an empty or expired cache does not create a stampede.
- Preserve user expectations with read-your-own-writes routing, explicit version checks, or a bypass for a just-updated resource.
- Measure hit rate, stale reads, origin load, eviction, cache errors, and freshness lag. Remove a cache that adds complexity without meeting a measurable latency or capacity target.
The Framing
Your e-commerce platform caches product data in Redis with a 1-hour TTL. A customer loads the product page and sees a price of $29.99. Twenty minutes ago, the merchandising team changed the price to $24.99. The customer adds the item to their cart, and the cart service reads the fresh price from the database: $24.99. The customer sees a different price on the product page and the cart. They call support.
This is the staleness problem. The product page served cached data that no longer matches the source of truth. The cart service (which reads the database directly) showed the real price. The user's experience fractured because two services had different views of the same data.
The naive fix: "stop caching product data." But without the Redis cache, every product page view hits the database. At 100K page views per minute, the database falls over within minutes. The cache was not optional; it was structural.
The real question is not "should I cache this data?" It is "how stale can this data be before a user is harmed, and what invalidation strategy matches that tolerance?"
Teams can swing between two extremes: caching everything with long TTLs (fast but stale) or caching nothing because invalidation is "too complex" (correct but slow). The answer is almost always in the middle, with different staleness tolerances per data type.
How Each Works
Caching: trade freshness for speed
Caching stores a computed result so subsequent requests skip the expensive computation. The most common pattern is cache-aside (lazy population): check the cache first, fall through to the database on a miss, and populate the cache for the next reader.
def get_product(product_id: str) -> Product:
cache_key = f"product:\{product_id\}"
# Check cache first
cached = redis.get(cache_key)
if cached:
return deserialize(cached)
# Cache miss: fetch from DB
product = db.find_product(product_id)
redis.setex(cache_key, ttl_seconds=300, value=serialize(product)) # 5min TTL
return product
The speed benefit is dramatic. Redis responds in under 1ms. A PostgreSQL query with joins takes 5-50ms. At a 95% cache hit rate, 95 out of 100 requests are served 10-50x faster. The database handles only the 5% that miss the cache, reducing its load by 20x.
The cost: every cached value is a snapshot frozen in time. Until the TTL expires or the entry is explicitly invalidated, readers see the snapshot, not the current truth.
Freshness: always serve the source of truth
The "always-fresh" approach skips the cache entirely and reads from the primary database on every request. This guarantees that every response reflects the latest state.
def get_product_fresh(product_id: str) -> Product:
# Always read from the primary database
return db.find_product(product_id)
This is correct by definition but expensive. At scale, every read hits the database. Connection pools saturate. Query latency increases as load grows. You cannot horizontally scale a single-primary database beyond its hardware limits without adding read replicas (which introduce their own staleness via replication lag).
For your interview: never present "no caching" as a serious option for read-heavy data. The correct framing is choosing the right caching strategy and invalidation pattern, not choosing between caching and no caching.
Four cache interaction patterns
The differences between cache patterns determine who populates the cache and when.
| Pattern | Who populates cache | Write path | Staleness risk | Best for |
|---|---|---|---|---|
| Cache-aside | Application on read miss | App writes DB, invalidates/ignores cache | TTL window (seconds-hours) | Most use cases; simplest to implement |
| Read-through | Cache library on miss | Same as cache-aside | Same as cache-aside | Cache libraries with built-in loaders |
| Write-through | Cache on write | App writes to cache, cache writes to DB | Near-zero (cache always current) | Read-after-write consistency is critical |
| Write-behind | Cache on write, async DB flush | App writes cache only, cache flushes later | Data loss risk if cache crashes before flush | High write throughput, acceptable data loss |
Cache-aside is the default choice for 90% of use cases. Write-through when you need read-after-write consistency. Write-behind only when you can tolerate potential data loss.
Head-to-Head Comparison
Continue Reading with Premium
Unlock this article and every other in-depth system design guide on the platform with SDEpedia Premium.