Sooner or later it lands in your support inbox, in almost exactly these words: "I changed my plan hours ago and the dashboard still shows the old one." The ticket arrived the same morning as a deploy, which is how these tickets usually arrive. You do what any careful founder does first: open the database and look. The row is updated — correct, exactly as shipped. Then you load the dashboard the way the customer would, and there it is: the old plan, rendered confidently, hours out of date. Nothing is broken. The database is telling the truth. Something between the database and the customer's eyes is not — and that something is a cache. (This is a composite ticket, not a real one — but if you have shipped software to real users for more than a few months, you have met it, or you will.)
Key Facts
The Three Caches You Already Have (Even If You Configured None of Them): Caching is not a feature you opted into.
Why Deploys Make Staleness Visible: If all this caching is constant, why do the tickets cluster in the hours right after a deploy?
Cache Invalidation Basics: Four Strategies That Cover Most Cases: Invalidation is simply the discipline of deciding when a cached copy stops being trustworthy — and making sure something actually enforces that decision.
Match the Data to the Strategy: A Decision Table: Staleness tolerance is a product decision, not a technical one.
These tickets are uniquely maddening because every normal signal says the system is healthy. Monitoring is green. Logs are quiet. Health checks pass. The data is right. Yet a paying customer is looking at yesterday — because the application code is not the thing lying. A cache somewhere is serving a stored copy of the past, and it will keep doing so until something tells it to stop.
This guide covers the cache invalidation basics that decide whether your users see the new world or the old one after a deploy: the three caches your SaaS already has even if you never configured one; why deploys are the moment staleness becomes visible; the four invalidation strategies in plain language; a decision table for matching data to strategy; the asset-fingerprinting half most founders miss; a five-step routine for debugging a stale-data report; a walkthrough of the classic version; and a checklist to keep all of it honest. No computer science degree required — just a map of who is remembering what, and for how long.
The Three Caches You Already Have (Even If You Configured None of Them)
Caching is not a feature you opted into. It is a property of the web. Between your database and your customer's screen sit three layers that copy data and reuse it, and at least two of them were switched on by defaults you never reviewed. Founders who say "we don't use a cache" almost always mean "we never configured one." Here is what you are actually running.
The browser cache
Every user's browser keeps copies of what you send it: pages, scripts, styles, images, sometimes API responses — for as long as your response headers told it to, which can be seconds or a year. It serves exactly one entity: that user, on that device. That makes it the most personal cache and the least reachable. You cannot reach into a browser and delete anything; you can only send instructions — and if the instructions were permissive when the copy was stored, the browser obeys them until the copy expires. Anything you served once without saying "don't keep this" is out of your hands for the duration.
The CDN and edge cache
If your app sits behind a hosting platform, a CDN, or any "edge" layer — and most do by default — copies of your responses also live on servers around the world, close to your users. This cache serves everyone: one stored copy of a page or an API answer gets reused for every visitor until its time-to-live (TTL, the expiry you or the platform set) runs out. Defaults vary by platform and framework, from a few seconds to days — and many founders discover the edge cache exists precisely when it serves something stale to everybody at once. Two differences from the browser: staleness here is shared, and — the good news — it is at least configurable by you.
The application-level cache
The third layer lives inside your own code: a shared store like Redis, or plain in-process memoization — a variable your code fills with an expensive answer so the next request can skip the work. It serves your code, and it keeps whatever you put in it for whatever TTL you set — or forever, if you set none and nothing restarts. This is the layer you control completely, which makes it both the most useful and the most dangerous: a bug here is invisible in any browser's developer tools and usually invisible in monitoring too. One property matters most: an in-process cache dies with the process — every deploy wipes it — while a shared Redis cache survives deploys untouched, which is exactly when its contents start disagreeing with your new code.
Layer
Who it serves
How long it keeps data
Who controls it
Browser cache
One user, one device
Seconds to a year, per your response headers
You set the headers; the browser obeys
CDN / edge cache
Everyone (shared copies)
Seconds to days, per TTL settings and platform defaults
You, plus your platform's defaults
App-level cache (Redis or in-process)
Your code
Whatever TTL you set — or until a restart
Entirely you
Three layers, three memory spans, three different owners — a stale-data report can come from any one of them, which is why the debugging routine below finds the layer that lied before fixing anything.
Why Deploys Make Staleness Visible
If all this caching is constant, why do the tickets cluster in the hours right after a deploy? Because a deploy changes the code while the caches keep the data — and the two are only ever correct together. Staleness exists all the time, quietly. A deploy is the moment the old world and the new world are forced to meet.
New code meets old data
Your new release was written against a new shape of the world — a renamed field, an extra property, a different structure. But the cache still holds the old shape, written by the old code under the same key. The new code reads it, trusts it, and either renders nonsense or crashes on the field that is not there. The database is fine. The deploy was fine. The combination of new code and old cached data is the bug.
Old fragments render inside new pages
If you cache page fragments or partial responses, a deploy can update the page shell while the cached pieces inside it — the sidebar, the plan widget, the footer — still come from the old version. Users see a page that is half one release and half another. It looks like a rendering bug. It is a cache that outlived the code that produced it.
The cache hides the breakage from your health checks
Health checks usually exercise the short path: can the app start, can it reach the database, can it serve a simple response. The cached path — the one users actually hit — is not what the health check measures. So a release can pass every gate, ship, and still serve users a stale or broken experience the checks never touch. Green dashboards and angry customers are not a contradiction when the failure lives in a layer your checks bypass.
Two users, two versions: the "works for me" nightmare
The worst support shape arrives when the caches disagree with each other. One user's browser holds the old page referencing the old assets; another, arriving fresh, gets the new page and the new assets. Your product is now running two versions at once for different people, and when the first user's report lands, support opens the app, sees the new version, and replies "works for me" — leaving you to debug a bug that does not exist while the customer with the real problem waits.
Cache Invalidation Basics: Four Strategies That Cover Most Cases
Invalidation is simply the discipline of deciding when a cached copy stops being trustworthy — and making sure something actually enforces that decision. Every strategy in use is a variation on two ideas: let the copy expire on its own, or retire it actively when the data changes. Four strategies cover nearly everything a small SaaS needs.
1. TTL: cheap, and wrong for anything that changes in minutes
Set an expiry on the cached copy and walk away. When the TTL passes, the next reader misses the cache and refetches from the source. It is the cheapest strategy: no code in your write path, one number at write time. It is the right tool for data where minutes of staleness are genuinely acceptable — a public marketing page, an aggregate count, a feed of public content. It is the wrong tool for anything a user can change and expects to see changed, because the worst-case staleness is the entire TTL. A six-hour TTL is six hours during which a customer can be told, politely and repeatedly, that their own action did not happen. TTL is a bound, not a promise. Treat it as the safety net, never the plan.
2. Purge-on-write: the workhorse
Delete the key when the data changes. The next read misses, refetches from the database, and stores a fresh copy. This is the workhorse for user-visible data, because staleness drops from "one full TTL" to "one write." The cost is discipline: every code path that changes the data — admin edits, API updates, imports, background jobs — must purge the key, or the copy goes stale exactly as before. In practice you combine it with a long fallback TTL so a missed purge self-heals eventually instead of never:
# Illustrative write path — fictional pseudo-code, not a real frameworkfunction updatePlan(plan): db.save(plan) # source of truth first cache.delete("plan:" + plan.id) # then retire the cached copy
One line of deleting, attached to every line of writing — and a large share of the stale-data tickets in this article trace back to that line missing somewhere.
3. Versioned keys: let old entries go unreferenced
Instead of deleting the old copy, change its name. Include a version in the key — a data version you bump when the data changes, or the deploy version your build pipeline knows — so new code reads plan:v2:17 while the old plan:v1:17 entry sits unreferenced until its TTL expires and the store reclaims it. Nothing gets deleted in the hot path, half-updated mixes become impossible, and every reader sees one consistent world. The costs: orphaned entries consume memory until their TTL runs out (so versioned keys still need a bounded TTL), and something real has to bump the version — a deploy number, a migration counter, an updated-at stamp. As a bonus, versioned keys are the strategy that survives two versions of your app running at the same time — which, as the deploy section will show, is not a hypothetical.
4. Cache nothing on authenticated, personalized pages — the underrated option
The simplest invalidation strategy is never needing one. Data behind a login is per-user by definition; sharing a cached copy of it between users is a staleness bug and, worse, a data-leak bug. So do not cache it — not on the CDN, not in a shared store. Fetch it from the database per request. At small-SaaS scale that is milliseconds against an indexed row, and it deletes the entire class of problem: nothing cached, nothing to invalidate, nothing to leak to the wrong user. The underrated option earns its status because founders reach for caching by habit on pages where the honest answer is "the database answers this fast enough already." Reserve caching for what is shared, expensive to compute, and tolerably stale. None of those three describe a customer's own dashboard.
Match the Data to the Strategy: A Decision Table
Staleness tolerance is a product decision, not a technical one. You are deciding how long a customer may be shown the past, per kind of data. Writing the decision down takes ten minutes and prevents the next ticket:
Data type
Strategy
Max acceptable staleness
Where it is enforced
Marketing pages, docs, public content
TTL, purged on redeploy
Hours
CDN / edge
Plan names, feature labels, shared catalog data
Purge-on-write with a fallback TTL
Seconds
Application cache
Per-user dashboard and settings data
No shared cache — fetch fresh per request
None
Not cached anywhere shared
JS, CSS, images
Fingerprinted filenames with long TTLs
Until the next deploy
Browser + CDN
index.html / the app shell
no-cache: revalidate every request
None
Browser + CDN headers
Sessions and auth state
Never cached
None
Excluded by default everywhere
Two rows deserve a second look. Row two is the one founders skip: plan names and feature labels feel immutable until the day you rename a plan — which is exactly the walkthrough below. Row three is the one that feels wasteful and almost never is; "fetch it fresh" is a complete, correct strategy for most per-user data at your scale. If a row makes you nervous, the issue is usually the staleness budget, not the strategy — decide what the customer may tolerate, then pick the tool that guarantees it.
The Other Half: Asset Fingerprinting
User data is half the problem. The other half is your own code and styles, which change on every deploy while their filenames often do not. If your JavaScript is always called app.js, every cache between you and the user is justified in keeping yesterday's file — and the mismatch scenarios from the deploy section follow.
Fingerprinting fixes this at the filename level. Your build pipeline hashes the file's contents into the name — app.a3f9c2.js — and the hash changes whenever the contents change. A new build produces a new filename, which every cache treats as a brand-new resource with a clean slate. That unlocks the pairing that makes the whole system work:
# Illustrative pairing — fictional filenames, generic directives/assets/app.a3f9c2.js -> cache aggressively (a year is typical); it can never change/index.html (app shell) -> no-cache: revalidate with the server on every request
Long TTLs on fingerprinted assets are safe precisely because the files never change — the hash is the change. The short-lived HTML is what guarantees the browser asks you for a fresh document, receives the new hashed filenames, and fetches the new assets. Get the pairing right and the "two users, two versions" scenario mostly dies: a user's cached HTML revalidates, sees the new hash, and self-corrects on the next load.
One honest hedge: the exact header names and directives vary by framework, server, and CDN — Cache-Control with max-age, the difference between no-cache and no-store, validators like ETag, and each tool's own spelling of them. The pairing is universal; the syntax is not. Check your framework's docs for how it emits asset URLs and cache headers before hand-tuning anything, because a well-meaning hand-tuned header is a classic way to cache exactly the wrong half.
Debugging a Stale-Data Report in Five Steps
When the ticket lands, resist the urge to clear everything and hope. Five steps, in order, find the layer that lied and the invalidation that was missing:
- Reproduce with a fresh client. Open an incognito window, a different browser, or another device on a different network. A fresh client carries no browser cache and has seen no CDN copy. If the fresh client sees fresh data, the lie lives between you and that specific user — browser or edge. If the fresh client sees stale data too, the browser and CDN are largely innocent, and the app-level cache moves to the top of the suspect list.
- Bypass layers one by one. Query the database directly: is the data actually updated? (If not, stop — this is not a cache problem at all.) Then request the page bypassing the edge, against your app's origin — or with a one-off unique query parameter, which most CDNs treat as a different, uncached URL. Then restart the app process or flush the application cache. Whichever bypass changes the answer is the layer that has been answering.
- Check which layer answered. Response headers will often confess: cache-hit indicators, the age of the stored copy, the TTL it was given — exact names vary by CDN and framework, so check yours. For the app-level cache, log the key and whether each read was a hit or a miss, at least in staging. "Which layer answered" is the single most valuable sentence in any stale-data investigation, and it is almost always written down nowhere.
- Look at what the deploy changed. New code reading an old cached shape under an unchanged key? A restructured fragment whose cached copy was never purged? A new asset referenced by an old cached HTML document? The deploy notes you already have usually contain the answer — staleness is almost always the shadow side of a change you made on purpose.
- Fix the invalidation, not the symptom. Clearing the cache closes today's ticket and guarantees the next deploy reopens it. Add the missing purge-on-write, bump the key version, exclude the data from caching, or fix the asset pairing — whatever makes the layer incapable of repeating the lie. Then verify on the next real deploy, not just today's hotfix, because the failure mode is deploy-shaped and only a deploy can test the fix.
A Stale-Cache Walkthrough: The Six-Hour Plan Label
(Fictional and illustrative — the details are invented; the shape will be familiar.)
Taskloop, a fictional project-management SaaS, renames its "Pro" plan to "Growth" and ships the change in a routine deploy. The migration updates the plans table, the release goes green, the founder closes the laptop.
Six hours of quiet weirdness follow. The dashboard header shows "Growth" for some users and "Pro" for others. The billing page, which is never cached, says "Growth" for everyone. Tickets trickle in: "which one am I actually on?" Nothing errors. Logs are clean. Health checks pass, because the health check asks the database, and the database is delighted.
The next morning, the founder runs the five steps. A fresh client still sees "Pro," so the browser and the CDN are innocent. The database says "Growth," so the source of truth is fine. The application cache is the remaining suspect, and a log line confirms it: a key called plan-labels, cached with a six-hour TTL, set eighteen months earlier when the label was added and never revisited — because plan labels "never change." The TTL was the entire invalidation strategy, chosen on the assumption the data was immutable — and the rename violated that assumption silently, while the TTL spent six hours faithfully serving the proof.
The fix takes the shape this article has been building: every write to the plans table now purges plan-labels — purge-on-write, the workhorse. The key becomes plan-labels:v2, so the old entries simply go unreferenced — versioned keys. The TTL drops to one hour as a self-healing safety net rather than the plan — TTL, demoted to its proper job. The next rename — there is always a next rename — ships without a ticket.
Where Deployxa Fits — and Where It Doesn't
Everything above is application architecture, and it stays that way regardless of where you host. That said, the deploy layer around your cache is worth being precise about, because for a few minutes around every release the old and new worlds genuinely coexist. On Deployxa:
- Blue/green style deployments put the new version in a standby slot, verify it, then switch traffic. For a short window, the old version and the new version both run. If they share a Redis cache — and most apps do — they share one keyspace. Design for it: versioned keys so the new release never reads entries shaped by the old one, and shape-stable entries so a write from either version cannot corrupt the other's reads. If your keys would break under two writers, the deploy is not your bug; the key design is.
- Releases are health-gated. The new version's health is verified before it takes traffic, so a release that cannot serve fails at the gate instead of in front of customers. One honest caveat, though: a health gate verifies that the new version serves, not that every cached copy everywhere is fresh. Staleness sails through health gates by design — which is why the checklist below, not the deploy gate, is what watches it.
- Logs are available per deployment from the dashboard. Step four of the debugging routine — look at what the deploy changed — is much shorter when releases, logs, and timing are visible per deployment instead of scattered across SSH sessions.
And the honest limits: cache design is application architecture. Deployxa does not manage your app's internal cache, your Redis TTLs, your framework's fragment cache, or the cache headers your application sends — those decisions, and their bugs, remain yours. The docs cover how the platform's release mechanics work; the three layers, the four strategies, and the one purge-on-write line of code are the owner's half of the bargain.
Your Cache-Health Checklist
The whole article, compressed to one page:
- You can name all three layers — browser, CDN/edge, app-level — and say what each one caches today
- Every cached, user-visible item has a written staleness budget, and its strategy guarantees it
- Purge-on-write exists for every kind of data users change and expect to see immediately
- Every application-cache key carries a bounded TTL as the safety net, even purged ones
- Authenticated, per-user pages are not shared-cached anywhere — CDN included
- JS and CSS ship under fingerprinted filenames with long TTLs
- The app shell (index.html) revalidates with the server on every request
- Cache keys survive two versions running at once — no new data shape under an unchanged key
- "Fresh client first" is the standing first move on every stale-data ticket
- After each deploy, one cached user-visible value gets checked before you close the tab
Give One Value the One-Hour Treatment
Before your next deploy, do the one-hour version of this article. Write your three cache layers on a single page: what each one holds, for how long, and who retires it. Then pick the single most user-visible value your application caches, give it purge-on-write with a fallback TTL, and deploy something small to a staging or non-production project to watch the value update immediately — never rehearse first on the system your paying customers use. If you also want the deploy half handled — health-gated releases into a standby slot and per-deployment logs while you keep full ownership of the application side — that is exactly the division of labor Deployxa is built for. Either way, the habit is the point: every cache is a claim about the future ("this will still be true"), and invalidation is how you keep the claim honest.