Mastering Caching Strategies for High-Performance Web Applications
Boost your web application's speed and scalability with proven caching strategies. From browser caching to Redis, learn how to optimize for performance.

The bug that taught me to respect cache keys
A client once messaged me at 2am: customers in Germany were seeing prices in USD. The store was correct, the code was correct, the database was correct. The CDN was the problem. Someone had cached the product page at the edge with a cache key that ignored the currency cookie. One German shopper warmed the cache with a USD response, and every German visitor after that got served his copy until the TTL expired.
That's the thing about caching. The hard part isn't making it fast. Making things fast is easy. The hard part is being correct while you're fast, and almost every caching bug I've debugged in production comes down to one of two mistakes: the wrong cache key, or nobody deciding which layer owns freshness.
So let me skip the textbook definition. You already know caching stores a copy so you don't recompute it. Here's what actually bites you in production.
The four layers, and why people conflate them
A request passes through at least four caches before it hits your data: the browser, the CDN/edge, your application memory, and the database query layer. Most of the stale-data bugs I get paid to fix happen because two of those layers were treated as one. The code is fine. Stale data wins anyway because nobody wrote down which layer is responsible for freshness on which route.
When I audit a site, I separate them on paper first. Browser cache. CDN cache. App data cache. DB query cache. Then for every route or API response, I write the freshness rule. Boring, but it's the difference between fixing the bug and moving it somewhere you can't see it.
Browser cache
The fastest request is the one the browser never sends. For hashed, immutable build artifacts — your bundled JS and CSS with a content hash in the filename — set a long max-age and mark them immutable:
Cache-Control: public, max-age=31536000, immutableA year is fine here because the filename changes when the content changes. The immutable flag tells the browser not to even bother with revalidation requests during a reload.
For HTML, never do this. HTML is the index into your hashed assets. Cache the HTML for a year and a user can be pinned to an old bundle that references files you've already deleted from R2. I've seen "white screen on deploy" tickets that were nothing but an HTML doc cached too aggressively.
ETags are the other half. The browser sends back the ETag it has, and if nothing changed the server answers 304 Not Modified with no body. Bandwidth saved, freshness preserved. Just know that some setups generate ETags per-server-instance, so behind a load balancer the same file can hand out different ETags and revalidation stops helping. Check that before you trust it.
CDN / edge cache
This is where I see the most damage, because it's the layer with the most leverage and the least visibility. Cloudflare, CloudFront, Fastly — they cache near the user, which is great for round-trip time, and catastrophic when the cache key is wrong.
A cache key is the fingerprint the CDN uses to decide "is this the same response I already have." If your key doesn't include everything that changes the response, you serve one user's response to another. Country, currency, language, auth state, A/B experiment bucket, device class — if the body varies by it, the key has to include it. My German-pricing incident was a missing currency dimension, full stop.
stale-while-revalidate is genuinely useful for public content that can tolerate being a few seconds behind. It serves the cached copy instantly and refreshes in the background, so nobody waits on the origin. The trap: it will happily keep serving a stale page while your origin is throwing 500s, because a failed revalidation doesn't evict the good copy. Set the window deliberately. I've watched a broken deploy stay invisible for hours because SWR was masking the errors and the dashboards looked green.
I go deeper on the Shopify-specific version of this — purging the edge after a theme or price change — in my Shopify cache busting guide.
Application cache
Caching computed results in your app's memory. Fine for expensive work that's the same for everyone. The catch is the moment you run more than one instance behind a load balancer: a local in-process cache drifts per instance, so user A hits instance 1 and sees fresh data, user B hits instance 2 and sees old data, and the bug only reproduces one time in N. That's the worst kind of bug. When the value has to be consistent across instances, it belongs in a distributed cache, not in process memory.
Database query cache
Read-heavy endpoints usually bottleneck on the database, and this is the layer with the clearest payoff. Redis is what I reach for — in-memory, fast, real data structures (hashes, sets, sorted sets, lists), and persistence when you want it. Memcached is the simpler multithreaded option if all you need is plain key-value.
Concrete case: an e-commerce dashboard showing total sales. Summing millions of transaction rows in Postgres on every page load is a self-inflicted wound. Compute it once, write it to Redis with a short expiry, serve the cached number to everyone until it expires:
async function getTotalSales() {
const cached = await redis.get('dashboard:total_sales');
if (cached !== null) return Number(cached);
const total = await db.sum('transactions.amount'); // the expensive query
await redis.set('dashboard:total_sales', total, 'EX', 300); // 5 min
return total;
}A slow aggregate query becomes a single-digit-millisecond lookup. Just don't pretend the dashboard is real-time — it's now up to five minutes stale by design, and that's a product decision, not an accident.
Cache invalidation: hard, but usually over-engineered
There's the famous line about the two hard things in computer science being cache invalidation and naming things. People quote it and then immediately overcomplicate the invalidation.
Here's my honest take after years of this: most teams reach for write-through caching and event-driven invalidation pipelines on data that would've been perfectly fine with a 60-second TTL. They build a fan-out system to keep a product-recommendations cache "consistent" — recommendations, which nobody on earth notices being a minute old. That's effort spent buying a guarantee the product didn't ask for.
The three strategies, and when I actually use each:
- TTL. Set a time-based expiry and walk away. This is the right default far more often than people admit. Blog feeds, recommendations, dashboards, leaderboards — anything where "up to N seconds old" is an acceptable answer. The whole strategy is one number.
- Cache-aside (lazy loading). Read tries the cache; on a miss it loads from the DB and populates the cache. On a write, you explicitly delete the key so the next read repopulates. Delete, don't update — deleting is idempotent and you can't write a half-stale value into the cache by racing yourself. This is my workhorse.
- Write-through. Update cache and DB together on every write. Strong consistency, but you pay write latency and a real consistency headache when one of the two writes fails. I reserve this for the genuinely small set of data where a user will notice staleness — their own profile, their cart, their order status. The rule of thumb: does the same person who made the change expect to see it on their very next request? If yes, write-through or an explicit purge on that path. If not, a TTL is doing the job and the pipeline is overhead.
Invalidation is hard because it's a distributed systems problem wearing a key-value costume. But "hard" is an argument for picking the simplest strategy that meets the actual freshness requirement, not for building the most elaborate one.
How caching bugs actually show up
They're quiet. Caching almost never throws an exception. A shopper sees yesterday's price, an admin sees an old order state, someone gets a personalized response that belonged to a different person. I treat these like production incidents, not like a performance tuning pass, because the failure is silent and the blast radius is every user who hits the warm copy.
The ones I run into over and over:
- Personalized HTML cached at the edge without a
private/no-storeboundary, so one logged-in user's page leaks to the next visitor. - CDN cache key missing country, currency, or experiment variant. (Hi, German pricing.)
- Static assets cached perfectly while API responses get refetched on every navigation, because someone tuned the easy layer and forgot the data layer. There's more on shaving that kind of waste in my Next.js performance optimization notes.
- Database cache invalidated by time only, never on writes, so an edit "doesn't take" for up to a full TTL.
stale-while-revalidatehiding origin errors far longer than anyone intended.
A header pattern I trust for public HTML
Cache-Control: public, max-age=0, s-maxage=3600, stale-while-revalidate=86400
CDN-Cache-Control: public, max-age=3600
Vary: Accept-EncodingWhat this does: the browser holds nothing (max-age=0), so a user always revalidates and never gets pinned to a stale page, while the CDN (s-maxage) absorbs the traffic and can revalidate in the background for up to a day. Split-brain on purpose — shared cache caches, private cache doesn't.
I would not put this anywhere near account pages, carts, admin screens, or anything customer-specific. Public marketing and content pages, yes. Anything with a logged-in session, never — that's how you leak one person's data to another.
What I ship first
Observability before aggressive TTLs. Every time. A cache that's fast but invisible is impossible to trust the moment a client reports a stale-page bug, because you have no way to answer "which layer served that?" My order:
- Set response headers intentionally per route group. Not one global config that papers over the differences.
- Expose cache status in logs or a debug header so you can see hit/miss in production without guessing.
- Start with short TTLs on anything that changes, then lengthen only after watching real traffic. You can always raise a TTL; you can't un-serve a stale page someone already screenshotted.
- Write down the purge path for content, price, and inventory changes — the actual command or call, not "we'll figure it out."
- Test logged-in and logged-out separately. Most leak bugs only exist on exactly one of those paths.
Caching is one of the highest-leverage things you can do for speed, and on a store it ties straight into revenue — I get into that side of it in store speed and conversions. But it's leverage in both directions. Get the keys and the invalidation right and it's nearly free performance. Get them wrong and you're debugging a problem that doesn't show up in any error log, on a schedule set by a TTL you forgot you configured.
FAQ
Should I cache HTML pages? Public ones, yes — at the CDN, with the browser revalidating so it can't get stuck on an old copy. Logged-in or personalized HTML, no, unless you've got a watertight cache key and a private boundary. The default failure mode is leaking one user's page to another.
Why is my edit not showing up after I save it? Almost always a cache invalidated by time instead of by writes. The data's correct in the DB; a cache layer is serving a copy until its TTL runs out. Find the layer (usually app or DB cache), and add an explicit purge on the write path instead of waiting for expiry.
TTL or write-through? TTL unless the same user who made the change expects to see it on their next request. Profiles, carts, order status — those want write-through or an explicit purge. Feeds, recommendations, dashboards — a short TTL is simpler and nobody notices the lag.
How do I stop the CDN serving the wrong version to the wrong user? Put everything that changes the response into the cache key — country, currency, language, auth state, experiment bucket. If the body varies by it and the key ignores it, the CDN will eventually hand one user another user's response.
Redis or Memcached? Redis if you want real data structures or persistence, which is most of the time. Memcached if you genuinely only need plain key-value and want the simpler multithreaded model.
References worth checking before you trust a header
- MDN Cache-Control reference: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control
- Cloudflare cache documentation: https://developers.cloudflare.com/cache/
Want this built for you instead of DIY?
I'm Karan — a Top Rated Plus Shopify Expert ($300K+ earned, 100% Job Success). If you'd rather hand this to someone who's done it hundreds of times, let's talk.
🛠️Web Development Tools You Might Like
Tags
📬 Get notified about new tools & tutorials
No spam. Unsubscribe anytime.
Comments (0)
Leave a Comment
No comments yet. Be the first to share your thoughts!


