Cache Invalidation Strategies: Complete Guide

Cache Invalidation Strategies: Complete Guide covering TTL, write-through, stale-while-revalidate, tag-based purging, and how to avoid stampedes and stale data.

By Han JeongHo · Editor in Chief
Updated · 20 min read
Some links in this review are affiliate links. We may earn a commission at no additional cost to you — commissions never decide what we recommend. Read our methodology.

Cache Invalidation Strategies: Complete Guide

Here's a bold claim to start with: your cache is probably lying to users right now, and you have no way of proving otherwise.

Cache Invalidation Strategies: Complete Guide — featured image Photo by Towfiqu barbhuiya on Pexels

A colleague of mine once shipped a pricing update at 9 a.m. and spent the next four hours explaining to sales why customers still saw yesterday's numbers. The code was correct. The database was correct. The cache wasn't. Four hours. Over a number that had already been fixed.

That's the whole problem in one sentence. Phil Karlton's old joke — that the two hard things in computer science are cache invalidation and naming things — survives because it's accurate. Caching is easy. Knowing when the cached copy stopped being true is not.

This guide is written for engineers who already cache something (an HTTP response, a database query, a rendered page) and now need a defensible answer to "how do we make sure it's fresh?" No product pitches here. Just the mechanics, the standards, and the failure modes that actually page you at 2 a.m.

Here's what you'll walk away with:

  • A working vocabulary — TTL, staleness, coherence, revalidation, and why they're not synonyms
  • Six invalidation strategies with the specific conditions where each one is the right call
  • The failure modes that bite in production: stampedes, split-brain caches, and unbounded key growth

Look, most teams pick a strategy by accident. They set a TTL of 300 seconds because someone typed it once in 2019 and nobody's touched it since. This guide is about picking on purpose.

Why Cache Invalidation Actually Matters

The cost of getting it wrong

Caching exists because reads outnumber writes, usually by a lot — 100:1 or 1000:1 in a typical read-heavy web app. A cached response can be served in single-digit milliseconds from memory or an edge node, versus 200–500ms for a database round trip plus rendering.

But every cache is a bet. You're betting the copy you stored is still true. When that bet loses, you get one of two bad outcomes:

Stale data served to users. Prices, inventory counts, permission changes, published/unpublished status. Some of these are cosmetic. Some are legal problems — an e-commerce site showing a withdrawn price, or a healthcare portal showing revoked access. Honestly, the second category is why I get twitchy when someone says "it's just a cache."

Cache misses that cascade. If invalidation is too aggressive, you flush everything and every request goes to origin at once. Your database, which comfortably handled 200 queries per second, now gets 20,000. This is the thundering herd, and it's how caching turns into an outage. The irony is thick: the thing you added for performance is now the thing that took you down.

The right invalidation strategy sits between those two cliffs.

Three misconceptions worth killing

"Short TTLs solve staleness." Nope. They reduce the window, not the problem. A 60-second TTL means you can still serve wrong data for 60 seconds, and you've multiplied origin load by roughly 5x compared to a 300-second TTL. You've traded one problem for another without eliminating either. This is my least favorite kind of fix — the one that feels productive and changes nothing.

"We can just purge on every write." Works great until you have fan-out. One product update might invalidate the product page, three category pages, the search index, the sitemap, and forty personalized recommendation blocks. Tracking that dependency graph by hand is where correctness goes to die.

"The CDN handles it." A CDN honors the headers you send. That's it. If your Cache-Control says max-age=86400, the CDN will happily serve a day-old response and it is behaving correctly. Invalidation is your responsibility; the CDN just executes.

Honestly, the third one causes the most incidents I've seen. People assume a layer is smarter than it is, and the layer is just doing exactly what it was told.

Core Concepts: The Vocabulary You Actually Need Photo by Malte Luk on Pexels

Core Concepts: The Vocabulary You Actually Need

Before the strategies, the vocabulary. Readers who skip this section tend to conflate terms that mean genuinely different things, and then argue past each other in design reviews for an hour.

Freshness vs. validity vs. coherence

Term Definition Practical question it answers
Freshness Whether a cached entry is within its declared lifetime "Has the TTL expired?"
Validity Whether the entry still matches the origin's current value "Is this actually correct?"
Revalidation Asking origin "is my copy still good?" without a full re-fetch "Can I reuse this cheaply?"
Coherence Whether all cache copies agree with each other "Do my three regions serve the same thing?"
Invalidation Explicitly marking an entry as unusable "How do I force a refresh now?"
Eviction Removing an entry for capacity reasons, not correctness "What gets dropped when memory fills?"

Note the split between freshness and validity. An entry can be fresh (TTL not expired) and invalid (origin changed). That gap is the entire subject of this guide. Everything else is detail.

Eviction is a separate concern entirely. LRU, LFU, and ARC decide what to drop under memory pressure. They don't decide what's correct. Don't rely on eviction for correctness — it's non-deterministic from your application's point of view, and "it'll probably get evicted eventually" is not a design.

The HTTP caching headers that matter

If you're caching over HTTP, the rules are standardized in RFC 9111, the IETF specification for HTTP caching. Worth reading the actual text at least once. It's drier than the MDN version but it settles arguments.

Directive Effect Typical use
max-age=N Fresh for N seconds in any cache Baseline TTL
s-maxage=N Fresh for N seconds in shared caches only Longer CDN TTL, shorter browser TTL
no-cache Must revalidate before each use (it does not mean "don't store") Frequently-changing but cacheable content
no-store Never write to disk or memory Authenticated or sensitive responses
stale-while-revalidate=N Serve stale for up to N seconds while refreshing in background High-traffic pages where latency beats perfect freshness
stale-if-error=N Serve stale for N seconds if origin errors Resilience during incidents
must-revalidate Never serve stale, even on origin failure Financial or transactional data
private Browser may cache; shared caches must not Per-user responses

The no-cache misreading is nearly universal — I'd bet on finding it in three out of four codebases I opened tomorrow. It means "store it, but check with origin before reusing." If you want nothing stored, no-store is the directive you're reaching for. MDN's Cache-Control reference documents each of these with examples.

Fun fact while we're here: must-revalidate is one of the few directives that changes behavior during an outage rather than during normal operation. It's a resilience knob disguised as a freshness knob, which is exactly why people set it without understanding what they've given up.

Validators: ETag and Last-Modified

Revalidation needs a validator — a token that lets origin answer "unchanged" cheaply.

ETag is an opaque string, typically a content hash. The client sends If-None-Match: "abc123"; origin replies 304 Not Modified with no body if it matches. Strong ETags ("abc123") mean byte-identical. Weak ETags (W/"abc123") mean semantically equivalent.

Last-Modified is a timestamp, paired with If-Modified-Since. One-second granularity, which is too coarse for anything that changes fast.

Use ETags where you can. Keep Last-Modified around as a fallback for clients or proxies that need it. A 304 response saves the entire payload — on a 200KB page, that's a ~99% bandwidth reduction for that request, for the cost of a few hundred bytes of headers.

The Six Cache Invalidation Strategies

This is the core of the guide. Each strategy below includes the mechanism, the trade-off, and the conditions that make it the correct pick.

1. TTL-based expiration (just let it expire)

Mechanism. Every entry gets a lifetime. When it expires, the next request re-fetches. That's it.

Trade-off. Simplest possible approach, zero coordination between writers and caches. In exchange you accept a bounded staleness window equal to the TTL.

Choose it when the data changes on a predictable rhythm, or when bounded staleness is genuinely acceptable. Weather data, exchange rates on a non-trading dashboard, aggregate analytics.

Concrete sizing. Ask: what's the maximum time a user can see wrong data before it costs us something? If the answer is "an hour," don't set 60 seconds — you're paying 60x the origin load for freshness nobody asked for and nobody will notice.

Cache-Control: public, max-age=300, s-maxage=3600

That says: browsers hold it 5 minutes, the CDN holds it an hour. Different layers, different tolerances. This split is wildly underused and it's free.

2. Write-through invalidation (purge on write)

Mechanism. When the application writes to the source of truth, it also deletes (or updates) the corresponding cache keys in the same code path.

Trade-off. Near-zero staleness. The cost is coupling — every write path must know every cache key it affects, forever, including the ones added by whoever joins the team next year.

Choose it when the write-to-key mapping is simple and stable. A user profile write invalidating user:{id} is fine. A CMS publish that touches thirty derived views is where this strategy starts to rot, and it rots quietly.

Delete or update? Delete is safer, and I'd say this is the single most-ignored piece of caching advice out there. If you update the cache directly, two concurrent writes can land out of order and leave the cache holding the older value permanently — not for a TTL, permanently. Deleting means the next read repopulates from the source of truth. The pattern is well documented in AWS's caching best practices guidance.

3. Tag-based / surrogate-key invalidation

Mechanism. Each cached response is labeled with one or more tags. Purging a tag invalidates every response carrying it, no matter how many keys that turns out to be.

Surrogate-Key: product-4821 category-shoes brand-acme

Update product 4821, purge the tag product-4821, and all forty pages that included that product drop at once. One API call.

Trade-off. Solves fan-out elegantly. Requires cache infrastructure that supports tags (Fastly surrogate keys, Cloudflare cache tags on Enterprise, Varnish with a bans/xkey module, Next.js revalidateTag).

Choose it when one entity appears across many cached views. This is the default for content sites, e-commerce catalogs, and anything with listing pages. If you're building a content site and not using tags, you're going to end up hand-rolling a worse version of them.

Watch the cardinality. Tag per entity is right. Tag per user session is not — you'll blow up the tag index and discover that your invalidation system now needs its own capacity planning.

4. Stale-while-revalidate (serve old, fetch new)

Mechanism. After the TTL expires, the cache serves the stale copy immediately and fetches a fresh one in the background. Defined in RFC 5861.

Cache-Control: max-age=60, stale-while-revalidate=600

Fresh for a minute. For the following ten minutes, stale-but-instant while a background refresh runs.

Trade-off. Users never wait on a cache miss. You accept slightly stale data on the first request after expiry.

Choose it when tail latency matters more than perfect freshness, and traffic is high enough that background refreshes stay warm. Homepages, feeds, dashboards.

But here's the catch, and it's a good one: on a low-traffic page, the refresh may not trigger for hours, and you'll cheerfully serve data far older than you intended while your metrics show a beautiful p99. Pair it with a hard max-age ceiling on genuinely time-sensitive content.

5. Versioned keys (cache busting)

Mechanism. Bake a version or content hash into the key or URL. app.a3f9c1.js instead of app.js. New content, new key — the old entry simply becomes unreachable.

Trade-off. Invalidation becomes a non-problem because you never invalidate. You just stop referencing the old key. The cost is that old entries linger until evicted, consuming space.

Choose it when you control the reference. Static assets are the canonical case: set Cache-Control: public, max-age=31536000, immutable on hashed filenames and you're done for a year.

Google's web.dev caching guidance treats this as the default for build artifacts. Honestly? It's the single highest-leverage caching change most teams can make, and it takes about an afternoon if your bundler doesn't already do it. Every other strategy on this list is a compromise. This one just deletes the problem.

6. Event-driven invalidation (pub/sub)

Mechanism. Writes emit events to a message bus. Cache layers subscribe and invalidate on receipt. Decouples the writer from the caches entirely.

Trade-off. Handles distributed, multi-service, multi-region setups where no single writer knows all the caches. The cost is real distributed-systems complexity: ordering, at-least-once delivery, replay, and the delightful failure mode where an event is dropped and a cache holds stale data indefinitely with nobody the wiser.

Choose it when you have multiple services or regions sharing a cache domain. Always pair it with a TTL backstop — the TTL is your recovery mechanism when an event goes missing, and events go missing. Never run event-driven invalidation with infinite TTLs. That's not a strategy, that's a bet on your message bus never having a bad day.

Strategy comparison

Strategy Staleness window Implementation cost Fan-out handling Best fit
TTL expiration Up to TTL Very low None Predictable-change data
Write-through purge Near zero Medium Poor Simple key mapping
Tag-based purge Near zero Medium-high Excellent Content, catalogs
Stale-while-revalidate TTL + refresh lag Low None High-traffic pages
Versioned keys Zero Low N/A Static assets
Event-driven Sub-second to seconds High Good Multi-service systems

Most production systems run three or four of these simultaneously at different layers. That's normal, not a design smell. If someone tells you a healthy system uses exactly one caching strategy, they're describing a system they haven't scaled yet.

A Framework for Choosing Your Strategy

Decision paralysis is common here. This sequence resolves it in about fifteen minutes per resource type — I've run it in meetings and it holds up.

Step 1 — Define your staleness budget per resource.

Write it down explicitly, in seconds, for each cached resource type. Not "as fresh as possible." That's not a number. An actual number. Example: product price = 30s, product description = 1h, category listing = 5m, static JS = infinite.

This single artifact prevents most caching arguments. When someone says "this feels stale," you check it against the budget instead of relitigating the whole design.

Step 2 — Classify the change pattern.

Does the data change on a schedule (TTL works), on an explicit user action (purge works), or unpredictably from an external system (event-driven or short TTL)? Three buckets, pick one.

Step 3 — Map the fan-out.

For one write to entity X, how many cached keys become invalid? If the answer is 1–3, write-through purge is fine. If it's more than about five, or if the count varies with data, go tag-based. Don't be brave here.

Step 4 — Pick a primary strategy and a backstop.

Every strategy needs a fallback for when it fails. Purges get dropped. Events get lost. The backstop is almost always a TTL. Set it to the longest staleness you could tolerate during an incident — often a few hours.

Step 5 — Instrument before you tune.

You need three metrics minimum: hit ratio, origin request rate, and staleness (measured as the delta between origin write time and the first request served with fresh data). Without staleness measurement, you're guessing with extra steps.

Step 6 — Load test the invalidation path, not just the cache path.

Almost nobody does this. Seriously, almost nobody. Simulate a mass purge and confirm origin survives. This is where you discover you need request coalescing, and it's much nicer to discover that on a Tuesday afternoon than during a launch.

Worked example: product detail page

Applying the framework to an e-commerce PDP:

  • Staleness budget — price: 30s; stock status: 60s; description/images: 1h
  • Change pattern — price and stock change on explicit writes; description changes rarely
  • Fan-out — a price change affects the PDP, two category pages, search results, and the homepage carousel (5+ keys, variable)
  • Primary — tag-based purge on product-{id}
  • Backstops-maxage=300 so any dropped purge self-corrects in 5 minutes
  • Splitmax-age=0, s-maxage=300 so browsers always revalidate while the CDN absorbs the load

Stock status, which is the most volatile piece, gets pulled out into a separate client-side fetch with no-store. Don't let your most volatile field dictate the TTL for your entire page — that one habit will do more for your hit ratio than any amount of TTL tuning. That's the reflex worth building.

Common Mistakes to Avoid Photo by Ann H on Pexels

Common Mistakes to Avoid

Seven failure modes, roughly in order of how often they cause incidents.

1. The cache stampede (thundering herd)

A popular key expires. Five thousand concurrent requests all miss, all hit origin, and origin falls over. Classic.

Fix. Request coalescing (also called single-flight): the first miss acquires a lock and fetches; the rest wait for that result. Redis-based implementations and Go's singleflight package both do this. Add TTL jitter — 300 + random(0,60) seconds — so keys written together don't expire together. That jitter line is maybe five characters of code and it has saved more production databases than any dashboard ever built.

2. Caching authenticated responses in a shared cache

A user-specific page gets stored by the CDN and served to a different user. This is a data breach, not a bug. Treat it like one.

Fix. Cache-Control: private, no-store on any authenticated response. Verify your Vary header includes Authorization and Cookie where relevant. Audit this by hand — don't assume the framework got it right, because frameworks get it right until the one route where someone overrode a default.

3. Treating no-cache as "don't cache"

Covered above, but it belongs on this list because it recurs constantly. no-cache stores and revalidates. no-store doesn't store. Two directives, one letter of difference in how people remember them, wildly different behavior.

4. Unbounded key cardinality

Including a request ID, timestamp, or full query string in the cache key. Every request generates a unique key, hit ratio drops toward zero, and memory fills with garbage that will never be read again.

Fix. Normalize keys explicitly. Sort query parameters, strip tracking parameters (utm_*, fbclid, gclid), and allowlist the parameters that actually change the response. Allowlist, not blocklist — marketing will invent a new tracking param next quarter and won't tell you.

5. Purge without confirming propagation

The application fires a purge and moves on. If the purge fails — network blip, rate limit, API error — nothing notices, and stale data sits there indefinitely.

Fix. Treat purges as unreliable operations, because they are. Log outcomes, retry with backoff, alert on sustained failures. And keep the TTL backstop.

6. Caching error responses with a long TTL

Origin returns a 500 during a deploy. The cache stores it with the default TTL. Now everyone sees a 500 for an hour after the deploy is already fixed, and your team spends that hour convinced the rollback didn't work.

Fix. Explicit short TTLs (5–30s) for 5xx responses. Use stale-if-error so the cache serves the last good response during origin failures instead of caching the failure.

7. No staleness observability

You can't tell whether invalidation is working. Complaints arrive from users, not dashboards. This is the one that makes all the others worse, because it means you find out last.

Fix. Timestamp cached payloads at write. Emit the write-to-visible delta as a metric. Alert when p99 staleness exceeds the documented budget.

Real-World Scenarios

Scenario A: News site, breaking-news correction

A publisher caches articles at the edge with s-maxage=600. An article contains a factual error. Legal wants it corrected immediately, and by "immediately" they mean immediately.

What happens with TTL only. Up to 10 minutes of incorrect content served globally. Not acceptable.

Working design. Tag each article response with Surrogate-Key: article-{id} section-{name}. On publish or edit, the CMS purges article-{id}. Propagation across a major CDN completes in a few seconds. The 600-second s-maxage stays as the backstop for dropped purges.

Result. Correction visible in seconds; origin load unchanged because only one tag was purged, not the whole section.

Scenario B: SaaS dashboard, expensive aggregate query

An analytics widget runs a query that takes 8 seconds. Two hundred users load it every morning between 8:55 and 9:05 — which is, of course, exactly when everyone is also in a meeting looking at it on a shared screen.

What breaks. A plain 5-minute TTL means the key expires mid-rush, dozens of requests miss simultaneously, and the database queues up 8-second queries behind each other.

Working design. Three layers. First, max-age=300, stale-while-revalidate=1800 so expiry never blocks a user. Second, single-flight locking so only one request recomputes. Third, a scheduled warm job at 8:45 that pre-populates the cache before traffic arrives.

Result. Dashboard loads in under 200ms. The database sees one query per 5-minute window instead of dozens. The warm job is the unglamorous piece and it does most of the work.

Scenario C: Multi-region inventory service

Three regions, each with a local cache. A warehouse in one region updates stock. Users in the other two regions see the old count and oversell.

What breaks. Regional write-through purge only clears the local cache. This is a coherence failure, and it's invisible until customers complain — or until finance asks why you're refunding orders.

Working design. Inventory writes publish to a message topic. Each region's cache invalidator subscribes and purges locally on receipt. Every entry also carries a 60-second TTL as the backstop for dropped messages. Critically, the checkout path reads inventory with no-store — the final stock check never comes from cache. Ever.

Result. Cross-region convergence in roughly 1–2 seconds, with a hard 60-second worst case. Overselling risk moves to the checkout path, which doesn't cache at all.

The transferable lesson across all three: cache the read path, never the decision path. Display stock from cache. Decrement stock from the source of truth. If you take one sentence from this entire guide, make it that one.

Tools and Official Resources

Standards and vendor-neutral documentation only. No product recommendations.

Specifications

Reference documentation

Free diagnostic tools

  • RedBot.org — enter a URL and it audits your caching headers against the RFCs, flagging contradictions. Free, no signup, maintained by an HTTP spec editor. It is criminally underused.
  • Browser DevTools Network panel — the Size column shows (disk cache), (memory cache), or a transfer size, and the response headers show what your cache layers actually returned.
  • curl -I <url> — the fastest check for what headers you're really sending, past any framework defaults.

You Might Also Like


Frequently Asked Questions

What's the difference between cache invalidation and cache eviction?

Invalidation is about correctness — you remove an entry because it's wrong. Eviction is about capacity — the cache removes an entry because it needs the space, using a policy like LRU. Never depend on eviction to fix stale data; you have no control over when it happens.

How do I pick a TTL?

Start from the business question, not the technical one: how long can a user see outdated data before it costs something? That number is your ceiling. Then check origin capacity — if a 30-second TTL would generate more load than origin can handle, you need a longer TTL plus stale-while-revalidate, or a purge-based strategy instead. The failure mode here is picking a round number because it looks tidy. 300 is not a magic number.

Does no-cache prevent caching?

No. It means "store it, but revalidate with origin before each reuse." no-store is the one that prevents storage. Go grep your codebase for it today — I'd give it decent odds you find at least one wrong.

ETag or Last-Modified — which should I use?

ETag, when you can. It detects any content change, while Last-Modified has one-second granularity and misses faster edits. Sending both is fine and gives older clients a fallback. Just make sure your ETag generation is deterministic — a hash that varies across servers breaks revalidation entirely, and it breaks it silently, which is the worst way for something to break.

How do I stop cache stampedes?

Three techniques, best used together. Request coalescing so only one request recomputes on a miss. TTL jitter so keys don't expire in synchronized batches. Probabilistic early expiration, where a request has a small random chance of refreshing before actual expiry, spreading the work over time.

Can I invalidate a browser cache after the fact?

Not directly. You don't control the client, and that's the whole ballgame. Which is why hashed filenames matter: change the URL and the old entry becomes unreachable. For HTML, keep max-age short (or zero with a revalidation requirement) and let the referenced assets carry the long TTLs. Setting a year-long max-age on HTML is a mistake you cannot undo — those browsers are gone, and they're not coming back to ask you for a fresh copy.

Should the same TTL apply at every layer?

No, and this is a genuinely useful lever that most teams leave on the table. Use max-age for browsers and s-maxage for shared caches. A common pattern is max-age=0, s-maxage=3600 — browsers revalidate every time (cheap, a 304), while the CDN absorbs the traffic for an hour. You get fast invalidation via CDN purge without waiting for millions of browser caches to expire on their own schedule.

How do I verify invalidation is working in production?

Measure it. Timestamp the payload at cache-write time, then track the delta between an origin write and the first request that returns the new value. Chart the p50 and p99, alert when p99 exceeds your documented staleness budget. If you can't produce this number, you don't actually know whether your invalidation works — you just haven't been told it's broken yet.

The Bottom Line

Cache invalidation is hard because it's a distributed-consensus problem wearing a performance-optimization costume. You're asking multiple independent systems to agree on what's true, cheaply, without coordination. When you put it that way, it's a little surprising it works as often as it does.

The three things worth carrying out of this guide:

  • Write down a staleness budget in seconds for every cached resource. Most caching arguments are really disagreements about an unstated number. Make it explicit and half of them resolve themselves before the meeting starts.
  • Layer your strategies. Tags or events for fast correctness, TTL as the backstop for when those fail, versioned keys wherever you control the URL. No single strategy is sufficient alone, and anyone selling you one is selling you a future incident.
  • Instrument staleness, not just hit ratio. A 99% hit ratio on stale data is worse than a 70% hit ratio on correct data. Hit ratio measures cost; staleness measures correctness. Most dashboards only show you the first one, which is how teams end up feeling great about a cache that's quietly wrong.

Next step: pick your highest-traffic cached endpoint and run it through RedBot right now. It takes two minutes — less time than it took to read this section. In my experience, the first audit almost always surfaces something surprising: a no-cache that was meant to be no-store, a missing Vary, or a max-age nobody on the current team remembers setting. Fix that one thing, then work down the list.

Tags

cachingsystem-designweb-performancehttp-headerscdnbackend-engineering

For in-depth personal finance & investing strategy, see our sister publication: The Money Playbooks

About the Author

JH
JeongHo Han

Financial researcher covering personal finance, investing apps, budgeting tools, and fintech products. Every recommendation is based on hands-on testing, not marketing claims. Learn more