Most performance problems in web apps are not solved by faster code. They are solved by not running the code at all. That is what caching does. The hard part is not adding a cache, it is deciding which layer should hold the data, how long it should live there, and how it gets thrown away when the underlying data changes.
This guide breaks down the four caching layers every web application should consider, with concrete headers, TTL values and invalidation rules for each one. It also covers the two failure modes that cause most caching incidents: stale data served to the wrong user and cache stampedes that take down the origin.
The four caching layers at a glance
| Layer | Where it lives | Best for | Typical TTL | Invalidation |
|---|---|---|---|---|
| 1. Browser | End user device | Static assets, fonts, images, API responses that rarely change | 60s to 1 year | Impossible to purge. Use fingerprinted URLs |
| 2. CDN / edge | PoP close to the user | Anonymous HTML, assets, public API endpoints, images | 30s to 30 days | API purge, surrogate keys, tags |
| 3. Application (Redis) | Shared memory store next to your app | Computed objects, sessions, per user data, expensive aggregates | 10s to 24h | DEL on write, key versioning, TTL |
| 4. Database | Inside or beside the DB engine | Heavy joins, reporting queries, hot rows | Seconds to hours | Refresh jobs, triggers, CDC events |
Rule of thumb: cache as close to the user as the data’s freshness requirements allow. Every layer you move down costs you a network hop, a serialization step and CPU time.

How to pick the right caching layer
Before writing a single line of caching code, answer these four questions about the response you want to cache. Source: https://namastedev.com.
- Is it identical for every user? If yes, the CDN is your best value. If no, skip to Redis.
- How stale can it be before someone complains? Write down a number in seconds. That number is your TTL, not a guess made at deploy time.
- Do you know exactly when it changes? If yes, use event based invalidation with a long TTL as a safety net. If no, use a short TTL and accept the churn.
- What does a cache miss cost? A 5 ms miss needs no protection. A 4 second miss needs stampede protection before it needs a cache.
A quick decision table
| Problem | Cache at this layer | Why |
|---|---|---|
| JS and CSS bundles re-downloaded on every page view | Browser | Zero network cost, content hash makes it safe |
| Marketing pages slow for users on another continent | CDN | Latency is geographic, not computational |
| Product page calls 6 microservices per render | Redis | You are caching the composed object, not the transport |
| Dashboard aggregate scans 40M rows | Database (materialized view) + Redis | The DB can pre-compute it far cheaper than the app can |
| Logged in user’s cart shown on every page | Redis, never CDN | Per user data at the edge is a data leak waiting to happen |
Layer 1: Browser caching
The browser cache is free bandwidth and zero latency. It is also the layer you cannot purge. Once a resource is in a user’s browser with a one year TTL, it stays there until it expires or the user clears storage. That single fact drives the whole strategy: only cache immutable things for a long time.
The two patterns you actually need
Pattern A: fingerprinted, immutable assets
Any file whose name contains a content hash can be cached forever, because a change to the content produces a new URL.
# /assets/app.9f2c1ab4.js
Cache-Control: public, max-age=31536000, immutable
The immutable directive tells the browser not to send a revalidation request even when the user hits reload. This is the single highest return caching header available to you.
Pattern B: mutable documents with revalidation
HTML and API responses change at unpredictable times. Serve them with a short freshness window plus a validator.
# /dashboard (HTML shell)
Cache-Control: private, no-cache
ETag: "a3f9c2"
# /api/products?page=1 (public list)
Cache-Control: public, max-age=60, stale-while-revalidate=300
ETag: "list-v42-8821"
no-cache does not mean “do not store”. It means “store it, but revalidate before reusing it”. A revalidation that returns 304 Not Modified costs a round trip but no payload, which is often a 95% bandwidth saving on a big JSON list.
Browser TTL reference
| Resource | Recommended header |
|---|---|
| Hashed JS / CSS / fonts | public, max-age=31536000, immutable |
| Unhashed images and media | public, max-age=86400, stale-while-revalidate=604800 |
| User avatars | private, max-age=300 |
| HTML shell (SPA) | private, no-cache plus ETag |
| Public JSON API | public, max-age=30, s-maxage=300 |
| Authenticated JSON API | private, no-store for anything sensitive |
Service workers: the programmable browser cache
A service worker lets you choose the strategy per request instead of relying on headers alone. The four strategies worth knowing:
- Cache first: serve from cache, only hit network on miss. Use for hashed assets and app shell.
- Network first: try network, fall back to cache. Use for anything where freshness beats speed, such as an inbox.
- Stale while revalidate: serve the cached copy instantly, refresh in the background. The best default for avatars, thumbnails and semi-static JSON.
- Network only: for POST, PUT and anything money related.
Warning: a service worker that caches your HTML shell with a cache first strategy and no update path will pin users to an old release. Always version your cache names and clean up old ones in the activate event.

Layer 2: CDN and edge caching
The CDN is where you get the biggest wins per hour of engineering effort. One shared copy at the edge serves thousands of users, and unlike the browser cache, you can purge it.
Separate browser TTL from edge TTL
This is the trick most teams miss. s-maxage applies only to shared caches (CDN, reverse proxies), while max-age applies to the browser. Set a short browser TTL and a long edge TTL, then purge the edge when content changes.
# Blog article, editable at any time
Cache-Control: public, max-age=0, s-maxage=86400, stale-while-revalidate=60, stale-if-error=86400
Surrogate-Key: article-1834 blog-index author-22
What this gives you:
- Browsers always revalidate, so a purge takes effect immediately for everyone.
- The edge holds the page for 24 hours, so your origin sees almost no traffic.
stale-if-errorkeeps the site up for 24 hours if your origin returns 5xx.- Surrogate keys let you purge “everything by author 22” in one API call.
Cache key normalization
Your hit ratio is destroyed by cache key explosion. Every unique URL is a separate object at the edge, and tracking parameters create infinite variants.
- Strip marketing parameters from the cache key:
utm_source,utm_medium,fbclid,gclid,ref. - Sort and allow-list query parameters.
?b=2&a=1and?a=1&b=2should be one object. - Be brutal with
Vary.Vary: User-Agentmeans thousands of copies of the same page. UseVary: Accept-Encodingand, if you must, a normalized device class header set at the edge. - Never let
Vary: Cookiereach a page you intended to cache publicly. It effectively disables caching, because every session cookie is a new key.
Purge rules that hold up in production
- Purge by tag or surrogate key, not by URL. When an article changes, one tag purge clears the article, the category page, the sitemap and the RSS feed.
- Purge after the write commits and the read replica has caught up, otherwise you re-cache the old value instantly.
- Prefer soft purge (mark stale, revalidate in background) over hard purge on high traffic objects. A hard purge on a popular page sends every concurrent request to the origin at once.
- Keep a global emergency purge runbook, and make sure your origin can survive a full cold cache. If it cannot, you do not have a caching strategy, you have a dependency.
Layer 3: Application caching with Redis
Redis is where you cache things the HTTP layer cannot: per user objects, partial computations, rate limit counters, session state and expensive aggregates shared across all your app servers.
Cache aside is the default, and here is why
In the cache aside (lazy loading) pattern, the application checks the cache, and on a miss it loads from the database and writes back. Only requested data ever gets cached, so memory tracks real demand.
async function getProduct(id) {
const key = `product:v3:${id}`;
const hit = await redis.get(key);
if (hit) return JSON.parse(hit);
const row = await db.products.findById(id);
if (!row) {
// cache the negative result briefly to absorb scans for missing ids
await redis.set(`${key}:missing`, '1', 'EX', 30);
return null;
}
const ttl = 600 + Math.floor(Math.random() * 120); // jitter
await redis.set(key, JSON.stringify(row), 'EX', ttl);
return row;
}
Comparing the write patterns
| Pattern | How it works | Use when | Risk |
|---|---|---|---|
| Cache aside | App reads cache, loads DB on miss, writes back | Read heavy, unpredictable access | First request is always slow, stampede risk |
| Read through | Cache library loads from DB itself | You want one code path and built in coalescing | Less control over serialization |
| Write through | Every write updates DB and cache together | Data read immediately after write | Slower writes, caches data nobody reads |
| Write behind | Write to cache, flush to DB asynchronously | Counters, view counts, telemetry | Data loss if the cache node dies |
| Refresh ahead | Recompute hot keys before they expire | Small set of very hot, very expensive keys | Wasted work on keys that go cold |
Key naming and versioning
Adopt a scheme like entity:version:identifier:variant from day one:
product:v3:1842cart:v1:user:9931search:v2:sha1(query+filters):page:2feature-flags:v5:global
The version segment is your escape hatch. When you change the shape of the cached object, bump v3 to v4 and the entire old generation becomes unreachable and expires on its own. This beats writing a migration or flushing the whole database, which would send every request to the origin at once. For the wider picture, see Caching Strategies Across Application Layers.
Redis TTL guidance
| Data type | TTL | Invalidation trigger |
|---|---|---|
| Product / entity object | 10 to 30 min | DEL on update event |
| Inventory / price | 5 to 30 s | TTL only, changes are constant |
| Search results page | 2 to 10 min | Version bump on index rebuild |
| User session | Sliding, 30 min to 14 days | DEL on logout and password change |
| Permissions / roles | 1 to 5 min | DEL on role change, keep TTL short |
| Third party API response | 1 to 24 h | TTL plus manual purge endpoint |
| Negative results (404) | 15 to 60 s | TTL only |
Configure eviction on purpose
If Redis is a cache, set maxmemory and maxmemory-policy allkeys-lru (or allkeys-lfu when a small set of keys is far hotter than the rest). The default noeviction policy makes writes fail when memory fills up, which turns a cache pressure event into an outage.
If the same Redis instance also holds queues or sessions you cannot lose, split it into two instances. Mixing durable data and evictable cache in one node is a classic incident source.
Do not forget the in-process cache
A local LRU map inside each app process is a legitimate layer above Redis, and it is measured in nanoseconds instead of milliseconds. Use it for feature flags, config, currency tables and small reference data, with a 5 to 60 second TTL so that all instances converge quickly. Just accept that it is not coherent across nodes, so never use it for anything a user can change and expect to see immediately.

Layer 4: Database caching
The database has its own caching machinery, and using it well can be cheaper than moving the problem into your application.
What you can cache at this layer
- Buffer pool / shared buffers: the pages the engine keeps in RAM. If your working set does not fit, no application cache will fully rescue you. Right-size the instance first.
- Materialized views: pre-computed query results stored as a real table. Ideal for dashboards, leaderboards and reporting aggregates.
- Summary tables updated by triggers or CDC: when a materialized view refresh is too coarse, maintain counters incrementally.
- Read replicas: not a cache technically, but the same intent, offloading reads. Remember replication lag is a form of staleness you must design around.
- Prepared statements and plan reuse: caching the plan, not the data, and often worth several milliseconds per query.
Materialized view example
CREATE MATERIALIZED VIEW sales_by_day AS
SELECT date_trunc('day', created_at) AS day,
store_id,
count(*) AS orders,
sum(total_cents) AS revenue_cents
FROM orders
WHERE created_at > now() - interval '90 days'
GROUP BY 1, 2;
CREATE UNIQUE INDEX ON sales_by_day (day, store_id);
-- refresh without blocking readers
REFRESH MATERIALIZED VIEW CONCURRENTLY sales_by_day;
Schedule the refresh every 5 or 15 minutes, then cache the API response built from it in Redis for 60 seconds. Two cheap layers beat one expensive query.
When the database layer is the wrong answer
If the query is fast but called 50,000 times per minute, the fix is Redis or the CDN, not the database. Database caching helps when a single query is expensive. Application caching helps when a cheap query is called too often. Getting this backwards is one of the most common architecture mistakes we see during audits.
Cache invalidation rules that actually work
There are only three honest invalidation strategies. Pick one per dataset and write it down.
- TTL only. Simple, self healing, always eventually correct. Use it whenever “a few seconds or minutes old” is acceptable. The trade off is that you pay for a recompute even when nothing changed.
- Event based invalidation with a TTL safety net. The write path emits an event that deletes or updates the affected keys and purges the matching CDN tags. Always keep a TTL as a backstop, because events get lost.
- Immutable keys / versioning. Never invalidate anything. Change the key or the URL instead. This is what content hashing does for assets and what
product:v4:1842does for objects. It is the only strategy with no race conditions.
Delete, do not update
On a write, prefer DEL key over SET key newValue. Two concurrent writers that both SET can commit in the opposite order to their database transactions, leaving the cache permanently wrong. A delete forces the next reader to load the committed truth.
Order of operations on write
1. BEGIN transaction
2. UPDATE the database
3. COMMIT
4. DEL the Redis keys
5. PURGE the CDN surrogate keys
6. (optional) DEL again after ~500 ms to defeat a racing read that re-populated stale data
Step 6 (“delayed double delete”) looks crude but it removes the most common stale data race in read replica setups.

Cache stampedes: what they are and four ways to stop them
A stampede (also called dog piling or thundering herd) happens when a popular key expires and hundreds of concurrent requests all miss at the same moment. They all hit the database with the same expensive query. Latency spikes, connections run out, and the recovery attempt causes a second stampede. This is how a cache turns into an outage.
1. TTL jitter
If you warm 10,000 keys in a loop with EX 3600, they all expire in the same second. Add randomness:
const ttl = base * (0.85 + Math.random() * 0.3); // +/- 15%
2. Lock and coalesce (single flight)
Only one request is allowed to recompute a key. Everyone else waits briefly, or serves the stale value.
const lock = await redis.set(`lock:${key}`, '1', 'NX', 'EX', 10);
if (lock) {
const fresh = await expensiveQuery();
await redis.set(key, JSON.stringify(fresh), 'EX', jitter(600));
await redis.del(`lock:${key}`);
return fresh;
}
// no lock: serve stale copy or wait and retry once
return stale ?? await waitAndRetry(key, 50);
3. Stale while revalidate at every layer
Store the value with a logical expiry inside the payload and a physical Redis TTL that is much longer. When the logical expiry passes, return the stale value immediately and refresh in the background. Users never wait for a recompute, and the origin sees one request instead of a thousand. At the HTTP layer, the equivalent is the stale-while-revalidate directive.
4. Probabilistic early expiration
Each reader has a small, growing chance of refreshing the key before it expires. It spreads recomputation naturally with no locks:
const shouldRefreshEarly =
Math.random() < Math.exp(-beta * (expiresAt - now) / 1000);
Also plan for a cold start
A Redis failover or a cache flush empties everything at once. Protect the origin with concurrency limits, a circuit breaker and a warmup job that pre-populates your top few hundred keys after a deploy. Test it deliberately in staging: flush the cache during a load test and see whether the app survives.
Classic caching mistakes that cause stale data
- Caching a personalized response in a shared cache. One missing
privatedirective and user A sees user B’s account page. Enforce it with a middleware that refuses to sendpublicwhen a session cookie or Authorization header is present. - Caching error responses with a long TTL. A 500 from an upstream should get 5 to 10 seconds, never the normal TTL. Otherwise a one second incident lasts an hour.
- Forgetting the cache key is incomplete. Locale, currency, tenant, feature flag bucket and A/B variant all change the output. If they are not in the key, they are bugs.
- Invalidating one object but not the lists it appears in. Updating a product must also invalidate category pages, search results and the sitemap. Tags and key versioning solve this, ad hoc deletes do not.
- Purging before the transaction commits. The read that follows re-caches the old row instantly.
- Trusting the cache to be there. Every cache read needs a timeout (20 to 50 ms) and a fallback path to the origin. A hanging Redis call should not hang the request.
- Using the cache as a database. If losing a key breaks correctness rather than performance, it is not a cache.
- No observability. If you cannot see hit ratio per key prefix, you are guessing.
- Caching a 30 ms query for 24 hours. The staleness cost is real and the performance gain is not. Cache the slow things.

Measuring whether your caching strategy works
Track these from the first day, per layer and per key prefix:
| Metric | Healthy target | What a bad number means |
|---|---|---|
| CDN hit ratio (static) | Above 95% | Cache key explosion or missing headers |
| CDN hit ratio (HTML) | Above 70% for anonymous traffic | Cookies or Vary are blocking caching |
| Redis hit ratio | Above 85% | TTL too short, memory too small, or key churn |
| Evicted keys per minute | Near zero in steady state | Under-provisioned memory |
| Origin requests per second | Flat under traffic spikes | Stampede or expiry synchronization |
| p99 cache read latency | Under 5 ms for Redis | Big payloads, network hops, or blocking commands |
One more habit worth building: log the cache status of every response (X-Cache: HIT/MISS/STALE) and include the key version. Debugging a stale data report without that is guesswork. mozilla.org goes into the numbers.
A layered strategy you can copy
For a typical e-commerce or SaaS application, here is a configuration that works well in production:
- Browser: hashed assets at one year immutable, HTML at
no-cachewith ETag, authenticated API responses atprivate, no-store. - CDN: all assets and images cached for 30 days, anonymous HTML at
s-maxage=3600with surrogate keys per entity,stale-if-error=86400on everything, marketing parameters stripped from the cache key. - Redis: cache aside for entities with 10 minute jittered TTLs and versioned keys, sessions with sliding expiry, single flight locks on anything slower than 200 ms, separate instance from queues,
allkeys-lrueviction. - Database: materialized views refreshed every 10 minutes for analytics, read replicas for reporting, buffer pool sized to hold the hot working set.
Start at the layer with the best ratio of user impact to effort, which is almost always the CDN, then move inward as your profiling data dictates. Measure before and after each change so you know which layer is actually earning its complexity.
FAQ
What is the 80/20 rule in caching?
It is the observation that roughly 80% of requests target about 20% of your data. That skew is why caching works at all: a small cache holding the hot 20% can absorb the majority of traffic. Practically, it means you should profile your access distribution and size the cache to fit the hot set, rather than trying to cache everything.
What is the best caching strategy?
There is no single best one. Cache aside with a jittered TTL and event based invalidation is the best default for application data, because it only caches what is requested and heals itself if an invalidation event is missed. For static assets, immutable content hashed URLs are unbeatable. For anonymous HTML, edge caching with surrogate key purging wins. Pick per dataset based on how much staleness the feature can tolerate.
What is the difference between LRU and TTL?
TTL (time to live) is about correctness: it decides how long a value is allowed to be considered valid. LRU (least recently used) is about capacity: when memory is full, it decides which key gets thrown out first. You need both. A TTL without an eviction policy leads to out of memory errors, and an eviction policy without TTLs leads to stale data living forever in a cache that never fills up.
What are L1, L2, L3 and L4 caches in a web application context?
In CPU terms they are hardware cache levels on the processor die. In web architecture, developers borrow the naming to describe layers by distance from the request: L1 is the in-process memory cache inside the app instance, L2 is a shared store like Redis or Memcached, L3 is the CDN or reverse proxy, and L4 is the database’s own caching such as buffer pools and materialized views. The principle is the same: each level is larger, slower and shared by more consumers than the one above it.
How do I choose a TTL value?
Ask the product owner one question: how long can this be wrong before a user notices or it costs money? Convert that answer to seconds and use it. Prices and inventory get 5 to 30 seconds, product descriptions get 10 to 30 minutes, marketing pages get hours with tag based purging. When in doubt, start short, measure the hit ratio and origin load, then increase.
Should I cache API responses at the CDN or in Redis?
Cache at the CDN when the response is identical for all anonymous users and you can purge by tag. Use Redis when the response depends on the user, the tenant or a permission set, or when you want to cache the intermediate objects rather than the final payload so multiple endpoints can reuse them. Many teams do both: Redis for the composed data, CDN for the public rendering of it.
How do I prevent stale data after a deployment?
Bump the version segment in your cache keys as part of the release, ship new asset URLs via content hashing, and purge CDN tags for any template that changed. Never rely on a manual full flush, since an empty cache plus production traffic is exactly the cold start scenario that overloads your origin.
Need help auditing your caching layers? At Pixelseed we review real production traffic, map the four layers against your freshness requirements and hand you a prioritized plan with the headers, TTLs and invalidation rules ready to ship. Get in touch.