Caching is how you make a slow thing fast. It is also how you serve yesterday’s price, yesterday’s permission, and a logged-out header on a logged-in page. Every cache is a bet that the old answer is still good. When the bet is wrong, the bug looks supernatural: “I updated it but the API says the old thing,” “it works if I use incognito,” “only the EU region is wrong.”
You should still cache. You should know what you cached, how it is keyed, and how it dies.
This is a practical guide to HTTP caches, application caches, and CDNs for people who added Redis because a blog said so.
The questions before you cache
What is expensive? CPU, a query, a network hop, a render.
How stale is acceptable? A homepage hero can be a minute stale. A bank balance cannot. A permission check cannot be an hour stale unless you like IDOR.
What is the key? URL, user, locale, auth, A/B flag. Missing a key dimension is how user A sees user B’s cart.
How do you invalidate? Time (TTL), event (delete on write), version in the key (user:18:v7). If you cannot answer invalidation, you do not have a cache, you have a time bomb.
HTTP caching
Cache-Control, ETag, Last-Modified. Browsers and CDNs obey these, sometimes more aggressively than you wanted.
Cache-Control: private is for a single user. public can be stored by a CDN. Caching Authorization responses at a shared cache is a classic leak. Default to private for authenticated HTML and JSON.
no-store for secrets. max-age for static assets with hashed filenames. If the filename is app.js without a hash, you will fight old JS. Hash the asset. Cache forever. That is the one cache that is easy.
s-maxage for CDNs vs browsers. If you do not know the difference, read it before you set public, max-age=3600 on a personalized page.
CDN and HTML
The CDN will cache HTML if you let it. A logged-in page with public is how one user’s name appears for everyone, or how a sale price sticks after it ended.
Vary on Cookie sounds right and can explode cache hit rate. Often you do not cache personalized HTML at the CDN at all. You cache the CSS and JS and the public marketing pages.
Stale-while-revalidate is a smoothness trick. Know that users can see stale. Fine for a blog. Not fine for checkout stock if you promised accuracy.
Application cache (Redis, memory)
Cache the query result, not the world. Key: org:41:report:2026-08. TTL plus delete when the report’s data changes.
Stampede: when the TTL expires, 200 requests rebuild the same value. Use a lock, or staggered TTL, or singleflight. Otherwise the cache makes the outage worse.
Memory cache on one node is not visible to the other node. Sticky sessions paper over it. Then you add a second instance and the bug returns. Use Redis if the cache must be shared, or accept per-instance cache for truly cheap data.
Do not cache connection objects in a naive global if your test suite runs in parallel. That is a different article, but it will bite.
Cache aside vs write through
Aside: read cache, on miss read DB, fill cache. On write, update DB, delete cache key. Deleting is safer than updating the cache with a value you might get wrong. A short TTL is a safety net for missed deletes.
If you update the cache on write and forget a second key that stored the same data in another shape, you will debug for hours. Prefer fewer keys. Prefer delete.
Front-end caches
React Query, SWR, Redux persist. The user sees old data until refetch. staleTime is a product decision. After a mutation, invalidate the query. If you forget, the UI lies. That is your cache bug, not React.
Service workers: powerful, easy to trap users on an old app. Have an update story.
Debugging stale data
- Who served this, browser, CDN, app, client library?
curl -Ithe URL, look at cache headers andAge.- Try a cache-buster query. If it is fresh, a cache is in play.
- Check the key includes tenant and user if needed.
- Check TTL and whether the write path deletes the key.
Incognito isolates the browser cache. It does not isolate Redis. Do not stop at incognito.
A rule
Cache things that are slow and allowed to be stale, with a key that includes every dimension that changes the answer, and a delete on write or a TTL you can live with. If it must be right now, do not cache it, or cache it for a second with a clear UX that it might move.
HTTP caches and POST
GET and HEAD are the cacheable methods people mean. POST responses are not cached by default, which is good. A misconfigured CDN that caches POST is a horror story. Do not mark authenticated POST with public.
If you use GET for a “download report” that has side effects, you will cache the side effect. Use POST. GET should be safe.
Null objects and negative caching
Caching “not found” for a user id that is then created will 404 until TTL. Short TTL on negative cache, or do not negative-cache resources that are created often. Caching empty search results for five minutes is usually fine. Caching “no user” for an hour is how signup looks broken.
Multi-layer cache
Browser + CDN + Redis + ORM. Each layer can be right while the user sees wrong. Disable one layer at a time when debugging. The “it is Redis” guess is often the CDN. The “it is the CDN” guess is often the ORM identity map in a long-lived process.
Thundering herd after a deploy
You deploy, all TTLs were aligned, every box misses Redis at once. Randomize TTL with jitter. If you flush Redis on deploy “to be safe,” you create the herd on purpose. Prefer versioned keys and let old keys expire.
What “warm the cache” really means
A cron that hits popular keys can hide a cold start. It can also DDoS you if the popular set is huge. Warm a small set. Measure. Do not warm the entire table.
Cache keys and locales
/pricing cached without Accept-Language or a locale prefix serves English to a French user or the reverse. Include locale in the key or the URL. Currency is the same. A CDN that ignores Vary: Accept-Language will bite you. Prefer locale in the path for public pages.
Private caches and shared CDNs
A Cache-Control: private response that still sits on a misconfigured shared cache is a vendor bug you must test. Fetch the URL twice from two regions if you can. If you see someone else’s Set-Cookie, stop everything. That is not a performance issue. That is a leak.
TTL of zero
Some people “disable cache” with max-age=0 and still see caches because of heuristic caching or a layer that ignores it. Use no-store when you mean no store. Be explicit. Ambiguous headers are how you get “but I set max-age 0.”
Artikals will keep treating performance as a trade against correctness because that is the job. Caching is not a badge. It is a bug you schedule. Schedule it on purpose.