Caching Strategies for Web Applications Explained: Browser, CDN, Redis, and Database Layers

One of the most popular services we offer is ongoing website maintenance because most clients we work with become return clients.

Caching Strategies for Web Applications Explained: Browser, CDN, Redis, and Database Layers

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.

server cache

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.

  1. Is it identical for every user? If yes, the CDN is your best value. If no, skip to Redis.
  2. 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.
  3. 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.
  4. 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.

server cache

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-error keeps 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=1 and ?a=1&b=2 should be one object.
  • Be brutal with Vary. Vary: User-Agent means thousands of copies of the same page. Use Vary: Accept-Encoding and, if you must, a normalized device class header set at the edge.
  • Never let Vary: Cookie reach 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

  1. 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.
  2. Purge after the write commits and the read replica has caught up, otherwise you re-cache the old value instantly.
  3. 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.
  4. 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:1842
  • cart:v1:user:9931
  • search:v2:sha1(query+filters):page:2
  • feature-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.

server cache

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.

  1. 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.
  2. 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.
  3. 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:1842 does 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.

server cache

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 private directive and user A sees user B’s account page. Enforce it with a middleware that refuses to send public when 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.
server cache

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:

  1. Browser: hashed assets at one year immutable, HTML at no-cache with ETag, authenticated API responses at private, no-store.
  2. CDN: all assets and images cached for 30 days, anonymous HTML at s-maxage=3600 with surrogate keys per entity, stale-if-error=86400 on everything, marketing parameters stripped from the cache key.
  3. 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-lru eviction.
  4. 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.

Subscription Form

Contact Details

Quick Links

Copyright © 2022 Pixel Seed. All Rights Reserved.