
Why Your Cache Strategy Fails (and What a Good One Looks Like)
A single un-cached database query can stall a request by 40 to 80 milliseconds. Multiply that by 40 concurrent users and you have a page that takes over three seconds to paint. Most teams are not actually building a cache strategy; they are bolting Redis onto an app that invalidates wrong, and hoping the rate of stale-data bugs stays tolerable. If you have ever shipped a fix, then spent a weekend hearing "the old data is still showing," you already know the cost. This guide walks through cache layers, eviction policies, and the stale-data traps that trip up real deployments, with numbers you can plan against instead of slogans.

The Layered Cache Map: From Browser to Database
Caching is not one system; it is a stack of them, and each layer has a different latency cost. Browser caches give you sub-millisecond hits for static assets like CSS, JavaScript, and images, but you cannot control them from your server except through headers. A CDN such as Cloudflare or Fastly sits in front, serving cacheable responses from edge nodes in under 10 milliseconds, but only if you set Cache-Control and vary headers correctly. Application-level caches, typically Redis or Memcached, live in memory and answer in 0.1 to 0.5 milliseconds, which is why our database indexing basics guide treats them as a first-class tuning lever, which is still tens or hundreds of times faster than a disk-backed query. Finally, the database has its own buffer pool and query cache that you can tune, but only indirectly; the database performance tuning workbook goes deeper on the knobs that matter.

The mistake is treating all of these as interchangeable. If your HTML is personalized per user, a CDN cache of that page can leak one visitor's session data to another request. If your static assets are cached for a year but your CSS bundle name never changes, users get a broken layout after every deploy — exactly the kind of failure the web performance optimization checklist exists to catch. You need to know, for each resource, which layer should own it and how long it should live.
Cache-Control Headers That Actually Behave
Most header bugs are not subtle; they are contradictory. A response with Cache-Control: no-cache is not "do not cache," it means "cache it but revalidate with the origin before using." Confusing that one directive has broken more deployments than any algorithm choice. Get the basics straight:

public, max-age=31536000, immutablefor fingerprinted static assets you will never mutate, such as hashed CSS and image bundles.no-cachefor HTML that changes but is cheap to validate, forcing a revalidation round-trip on every load.privatefor anything user-specific, which tells shared caches to skip it entirely while still allowing the browser cache.s-maxageto set a separate lifetime for intermediate caches so an API response can live longer at the CDN than in a user's browser.
When you change a fingerprinted file, the URL changes, so browsers fetch the new version automatically. When you fail to fingerprint, you depend on max-age expiring, and every release becomes a "just clear your cache" support ticket. If that phrase is in your vocabulary, this is the header section you are getting wrong.
Eviction Policies Compared (see also our crash course for the data side): LRU, LFU, TTL, and Sliding Expiry
An in-memory cache has finite capacity, so the real question is what gets evicted first. Least Recently Used (LRU), the default in Redis and Memcached, throws out the entry nobody has touched in the longest time. It is simple and works well for bursty access, but it has a blind spot: a stale item that one user frequently refers to can occupy a slot even when the remaining thousand users need something else. Least Frequently Used (LFU) counts hits over time and protects genuinely hot keys, at the cost of slightly more memory per entry to track frequency. Redis supports both; Memcached is LRU-only with a sliding threshold.

TTL-based expiry is not an eviction policy in the same sense, but teams often treat it as one, and that is where stale data sneaks in. A 300-second TTL on a product price means a price change can take up to five minutes to propagate, which is fine for a blog view count and dangerous for inventory. The worse pattern is an unbounded cache with no TTL: the key stays correct today, then a background job updates the source table, and every cache reader served old data for a week. Pair a TTL with your eviction policy, and keep the TTL short enough that a missed invalidation expires on its own.
Cache Invalidation: The Part Nobody Wants to Code
Eviction is about memory pressure; invalidation is about correctness. There are three main approaches, and you will likely combine them. Time-based invalidation is the TTL you already set, and it is the only one that self-heals, which is why even well-designed systems keep a safety TTL underneath everything else. Event-based invalidation deletes or updates a key when the underlying source changes, using something like a database write hook or a Redis Pub/Sub message, and it brings downtime to near-zero on the changed records. Write-through caching updates the cache in the same transaction as the source write, which keeps them consistent but doubles the write latency and makes cache writes part of your database-design-principles transaction planning.

The trap is relying only on event-based invalidation. If your invalidation message is dropped, queue-backed, or processed after the next read, you have a stale cache with no expiry to catch it. That is why the production rule of thumb is: event-based invalidation for the "fast path" that most reads hit, plus a short TTL as the backstop. Teams that skip the TTL almost always get burned exactly once, usually during a data migration.
Redis vs Memcached vs CDN vs In-Process Cache
| Platform / Tool | Key Features | Pricing |
|---|---|---|
| Redis | In-memory data store; LRU/LFU eviction, Pub/Sub, TTLs, persistence (RDB/AOF), many data types | Free open source; Redis Cloud free tier 30 MB, paid plans start around $15–30/month |
| Memcached | Pure key-value in-memory cache; multithreaded, very low overhead, LRU eviction only | Free open source; AWS ElastiCache Memcached from ~$15–35/month for a small node |
| Cloudflare CDN | Edge caching, Cache-Control honoring, image and HTML optimization, free tier with generous limits | Free tier; Pro ~$20/month, Business ~$200/month |
| Fastly | Real-time edge caching with VCL customization, instant purging, strong purge APIs | Entry ~$50/month plus bandwidth; usage-based beyond that |
| Hazelcast / in-process (local) | Embedded cache inside the app JVM; near-cache at zero network latency | Open source Hazelcast free; paid enterprise editions on quote |
| Varnish | HTTP accelerator in front of your origin; extremely high throughput, VCL logic | Free open source; Varnish Enterprise paid/quote |
The choice is not "which is fastest" but "which latency budget does each request tolerate." Static, rarely-changing HTML belongs at the CDN so it never touches your origin. Session and user-session data belong in Redis because it needs fast random access and TTL cleanup. Truly hot per-instance state can live in-process to avoid a network hop entirely, accepting that it is not shared across servers. Teams that pair caching with often find the biggest win is removing redundant writes before they ever hit a cache layer. If you are still unsure which fits your traffic curve, a useful comparison exercise is to estimate what percentage of your reads are cacheable in the first place; many teams discover they are caching content that invalidates on every write.
Stale-Data Traps: Hot-Cache Stampedes and Miss Cascades
A cache stampede happens when a hot key expires and hundreds of concurrent requests all miss, then hammer the database at once. A cold database at 3 a.m. that takes 800 milliseconds to warm looks like a slow endpoint, and the fix is a stale-while-revalidate pattern: serve the old cached value immediately while one request refreshes it in the background. Redis' GETDEL or a simple conditionally-updated lock both work, but the key insight is that only one process should regenerate at a time.
The miss cascade is nastier. You add a new query, it misses on the first read, and because you capped memory with LRU, it evicts an older hot entry, which then misses and evicts something else. Within minutes the whole cache has churned and every request is going to the database. Measure your M/R (miss-to-request) ratio on a dashboard, and alert when it climbs above roughly 10 to 15 percent on a normal business day, because a climbing miss ratio is usually the first signal that your working set no longer fits in memory.
How to Instrument Your Cache Like You Mean It
You cannot tune what you do not measure, so expose a small set of metrics on every cache layer: hit ratio, miss count, eviction count, and average latency split by hit versus miss. Most hosted Redis and CDN consoles expose these, and your app can log the hit/miss branch on every read in one or two lines. A healthy application cache sits in the 90-to-99 percent hit range for stable, hot data; anything under 80 percent for a read-heavy workload means either the working set is too big for memory or the TTL is far too short for the access pattern.
Also track invalidation count separately from eviction count. A high eviction count with a normal hit ratio just means memory is tight; a high invalidation count with a stable source means your event hooks are firing for rows that never change, which is wasteful. When you tune, change one variable at a time, re-run load tests, and keep a changelog of TTLs. Cache tuning is iterative, and the safest "production change" is a TTL that is testable in staging with a real workload rather than a synthetic benchmark.
For more, check out: and .
FAQ
What is the difference between "no-cache" and "max-age=0"?
Technically both force revalidation, but "no-cache" tells the cache it must revalidate with the origin before using a stored copy, whereas "max-age=0" means the stored copy is immediately stale and must also be revalidated. In practice many browsers treat them similarly, but if a proxy caches a response with only "max-age=0," some implementations may still serve it without a revalidation round-trip. The safest, explicit header for HTML that changes frequently is "no-cache", and you should pair it with the appropriate ETag or Last-Modified validation headers so revalidation is cheap.
Should I set a TTL even when I use event-based invalidation?
Yes, always. Event-based invalidation can drop a message, run late, or be skipped during a migration, and without a TTL you serve stale data indefinitely. A short safety TTL, say 60 to 300 seconds depending on how quickly your users need changes, acts as a backstop and caps the maximum staleness even when every event hook fails. This is one of the most reliable correctness rules in caching.
How do I prevent one user's data from leaking through my CDN cache?
For any response that varies by user, send a "private" Cache-Control header so shared caches skip it, and avoid caching user-specific HTML at the CDN edge at all. If you must cache a base page, fragment-cache only the public parts and render the personalized sections server-side or via the browser, never through the CDN. Also set a "Vary: Cookie" header and keep cookie-based cache keys scoped, because a shared cache that ignores your Vary header is a privacy incident waiting to happen.
Why does my hit ratio drop after every deploy even though I did not change settings?
If you change static asset filenames or code that inserts new cache keys, the previous keys stop being used, and the LRU policy evicts old entries to make room for the new access pattern. The drop is usually temporary, but you can soften it by pre-warming critical keys after a deploy, either from a warm-up script or by pointing load tests at the endpoint before real traffic arrives. If the ratio stays low for hours after deploy, your working set is bigger than the allocated memory and you need either more cache or a longer TTL.