Caching is one of the simplest and most powerful tools for making applications fast, but it's also one of the easiest to get wrong. The core idea is straightforward: store the result of an expensive computation or a slow network request so you don't have to redo it every time. The hard part isn't storing the data, it's deciding what to cache, where to cache it, and when to throw it away. Get those decisions wrong and you trade slow pages for stale or inconsistent data, which is often worse. This post walks through the main caching strategies for invalidation, the common places to put a cache, and the trade-offs that should drive your choice.
Watch out for caching the absence of something, like a null lookup or a 404. If you cache "this user doesn't exist" for an hour and the user gets created five minutes later, you've built yourself an hour-long outage for exactly that person. Negative caching is fine, and often useful for protecting against repeated lookups on bad IDs, but give it a much shorter TTL than your positive entries get.
One header combo worth knowing: Cache-Control: public, max-age=31536000, immutable. The immutable directive tells the browser it doesn't need to revalidate the asset even on a hard refresh, which normally triggers a conditional GET despite an otherwise-fresh cache entry. It only makes sense for assets that are genuinely content-hashed and never change at that exact URL, but for those it removes a surprising number of pointless requests.
Watch out: A CDN caching "API responses that don't vary per-user" is only safe if the origin actually varies the cache key correctly (via the Vary header or a distinct cache key per auth state). Forgetting this is a classic way to accidentally serve one user's personalized or authenticated response to everyone else at that edge node. If you're mixing public and private data behind the same endpoint, it's worth reading up on API gateway and BFF patterns, since a lot of these caching mistakes trace back to how the routing layer in front of your services is designed.
Tip: If you're on HTTP, the stale-while-revalidate Cache-Control directive gives you a middle ground the simple TTL example above doesn't show: the cache serves the (slightly) stale response immediately while revalidating in the background, so users never wait on a synchronous refetch. It's worth reaching for whenever "a little stale" is fine but a slow recompute on every expiry isn't.
Note: A CDN purge API rarely invalidates every edge node instantly, propagation across regions can take anywhere from a few seconds to a couple of minutes depending on the provider. If your deploy process depends on old content disappearing immediately everywhere, verify your CDN's actual purge propagation time rather than assuming it's instant.
Note: The example above has a subtle gap: if 1,000 requests arrive for the same missing key at once, all 1,000 can call compute_expensive_feed() simultaneously before any of them finishes writing to Redis (a cache stampede). Guard against this with a short-lived lock or "in-flight" marker per key, or a request-coalescing layer, so only one caller recomputes while the rest wait for the result.
Warning: With write-behind caching, an acknowledged write isn't durable until it's flushed to the database. If the cache node crashes or restarts before that flush completes, those writes are gone. Only use write-behind once you've measured that write-through latency is an actual bottleneck, and pair it with persistence or replication on the cache layer.
Tip: When many cache entries share the same TTL, they can all expire at once and slam your database with simultaneous recomputation requests (a "thundering herd"). Add a small random jitter to each TTL (e.g.,
300 + random(0, 30)seconds) so expirations spread out over time.
Why Caching Matters

Why Caching Matters
Every cache exists to avoid repeating work: a database query, an API call, a rendered HTML page, or a computed value. The payoff is lower latency and reduced load on backend systems. The cost is that a cache can serve outdated information if it isn't managed carefully. Most caching bugs in production aren't about the caching mechanism itself, they're about invalidation logic that doesn't match how the underlying data actually changes.
Cache Invalidation Strategies

Cache Invalidation Strategies
There's a well-known joke in computer science that there are only two hard problems: cache invalidation and naming things. The joke holds up because invalidation strategy directly determines the trade-off between performance and correctness. Three strategies come up constantly: TTL, LRU, and write-through, along with its cousin, write-behind.
TTL (Time-to-Live)
TTL-based invalidation assigns every cached item an expiration time. Once that time passes, the item is considered stale and is either refreshed or evicted on the next request.
SET user:1234 '{\"name\":\"Ada\"}' EX 300 # expires in 300 seconds
TTL is simple to reason about and requires no coordination with the data source, you just pick a duration. The trade-off is that it's a blunt instrument: set the TTL too long and users see stale data for longer than necessary; set it too short and you lose most of the caching benefit because you're recomputing constantly. TTL works best when staleness is tolerable and predictable, such as caching a product listing page that changes a few times a day.
LRU (Least Recently Used)
LRU isn't really an invalidation strategy in the sense of freshness, it's an eviction strategy for when the cache runs out of space. When the cache is full, LRU discards the item that hasn't been accessed in the longest time, on the assumption that recently used items are more likely to be used again soon.
LRU is what gives an in-memory cache (like an application-level LRU cache or Redis configured with maxmemory-policy allkeys-lru) a bounded memory footprint. It's excellent for keeping hot data resident without manually managing size, but it doesn't guarantee freshness on its own. You typically combine LRU eviction with a TTL or an explicit invalidation event so that data doesn't stay correct-looking long after it's actually stale.
Write-Through and Write-Behind
Write-through caching updates the cache at the same time as the source of truth: every write goes to the cache and the database together, synchronously. This keeps the cache consistent with the database at the cost of slightly higher write latency, since you're paying for two writes instead of one.
Write-behind (or write-back) caching defers the database write, acknowledging the request as soon as the cache is updated and flushing to the database asynchronously later. This is faster for the caller but introduces a risk window: if the cache crashes before the flush completes, you can lose data. Write-through is the safer default; write-behind is worth the added complexity only when write throughput is a real bottleneck.
Choosing Between Them
Strategy | Solves | Best for | Main risk |
|---|---|---|---|
TTL | Freshness over time | Data with predictable staleness tolerance (listings, dashboards) | Stale data until expiry, or wasted recomputation if too short |
LRU | Bounded memory usage | Hot-key workloads with limited cache size | Doesn't guarantee correctness by itself |
Write-through | Cache/DB consistency | Data that must never be stale after a write | Higher write latency |
Write-behind | Write throughput | High-volume writes where brief inconsistency is acceptable | Possible data loss on crash before flush |
In practice these strategies aren't mutually exclusive, a real system often layers them. For example, using write-through for consistency and LRU for memory bounds, with a short TTL as a safety net against any invalidation bugs.
Where to Put the Cache
Once you've picked an invalidation strategy, the next question is where the cached data should physically live. The three most common layers are the browser, a CDN, and an application-level cache like Redis. Each sits at a different distance from the user and the source of truth, which changes the latency you save and the staleness you risk.
Browser Cache
The browser cache lives on the user's device and is controlled almost entirely through HTTP headers:
Cache-Control: public, max-age=86400
ETag: \"a1b2c3\"
This is the fastest possible cache because there's no network round trip at all on a hit. It's ideal for static assets, JS bundles, CSS, images, that are versioned by filename, so a new deploy simply gets a new URL and the old cache entry becomes irrelevant. The downside is that you have very little control once content is cached on a user's machine: you can't invalidate it early, you can only wait out the TTL or change the URL.
CDN
A CDN caches content at edge locations geographically close to users, sitting between the browser and your origin server. It's the right layer for content that's shared across many users, marketing pages, images, API responses that don't vary per-user, because one cached copy at an edge node serves everyone in that region.
CDNs typically support both TTL-based expiration and explicit purge APIs, giving you a middle ground: fast by default, with a way to force-invalidate when content changes unexpectedly. The trade-off compared to the browser cache is an extra network hop (still much faster than hitting origin), and compared to Redis, less flexibility for per-user or highly dynamic content.
Application-Level Cache (Redis, Memcached)
An in-memory store like Redis sits on your backend, between your application code and your database. This is the layer to use for expensive, frequently repeated computations or queries that are specific to your application logic, session data, computed aggregates, per-user feeds, rate-limit counters.
value = redis.get(\"feed:user:1234\")
if value is None:
value = compute_expensive_feed(1234)
redis.set(\"feed:user:1234\", value, ex=60)
return value
Redis gives you the most control: you can combine TTL, LRU eviction policies, and manual invalidation (deleting a key when the underlying data changes) in whatever combination fits your data. The trade-off is that it's the layer closest to your origin, so it saves less latency than a browser or CDN cache. You still pay for the network hop from your app server to Redis and the cost of serving the response from your backend.
Comparing the Layers
Layer | Distance from user | Best for | Invalidation control |
|---|---|---|---|
Browser | None (local device) | Static, versioned assets | Low, rely on TTL or URL change |
CDN | Regional edge node | Shared, mostly-static content | Medium, TTL plus purge API |
Redis / Memcached | Backend/application tier | Per-user or computed dynamic data | High, full manual control |
Putting It Together
Most production systems use all three layers at once, each solving a different problem. A typical request for a user's dashboard page might look like this:
Static JS/CSS assets are served from the browser cache, versioned by build hash, with a long
max-age.The page shell or shared marketing content is served from the CDN, with a short TTL and a purge triggered on deploy.
The user-specific feed data is computed once and stored in Redis with a 60-second TTL, so repeated requests within that window skip the expensive computation, and an explicit cache delete fires whenever the user posts new content.
None of these layers replaces the others, they address different distances from the user and different kinds of data. The right question isn't "TTL or LRU?" or "CDN or Redis?" in isolation, it's which combination matches how fresh each piece of data needs to be and how far it can travel before that freshness requirement breaks down. If you want to see how these decisions fit into broader architecture choices, the system design learning platforms roundup is a good next stop, and if your caching sits behind async or event-driven services, it's worth checking how event sourcing and async messaging patterns handle invalidation triggers across services.
Conclusion
Caching looks simple on the surface, store it once, reuse it later, but the details of invalidation and placement are where systems succeed or fail. TTL is easy but blunt, LRU manages memory but not correctness, and write-through/write-behind trade latency for consistency. Similarly, the browser, the CDN, and an application cache like Redis each buy you speed at a different distance from the user, with different levels of control over staleness. Understanding these trade-offs, rather than reaching for a single default, is what separates a cache that quietly makes your app fast from one that quietly serves everyone stale data.
