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

Every performance problem eventually becomes a caching problem. You profile your API, find a slow database query, throw a cache in front of it, and move on. Six months later, a user reports stale data. Your team spends two days tracing the issue to a cache layer nobody documented. This cycle repeats across the industry because most teams treat caching as a quick fix rather than a first-class architectural decision.

The reality is that effective caching strategies for web applications require understanding multiple layers, each with distinct tradeoffs, failure modes, and invalidation characteristics. Getting this right separates applications that feel instant from those that feel sluggish — or worse, those that serve confidently wrong data.

The Caching Hierarchy: Four Layers You Need to Understand

Caching operates in a hierarchy, and requests flow through each layer before reaching your origin. Understanding this stack is prerequisite to making good decisions about where to cache what.

Browser Cache

The closest cache to the user. Zero network latency. Controlled entirely through HTTP response headers. When configured correctly, the browser never even makes a request — it serves assets directly from local storage. This is the most impactful cache layer for static assets and the most misunderstood for dynamic content.

CDN / Edge Cache

Geographically distributed nodes that intercept requests before they reach your servers. Cloudflare, CloudFront, Fastly, and similar services operate here. The CDN reduces latency by serving content from a node physically close to the user, and it absorbs traffic spikes that would otherwise overwhelm your origin.

Application Cache (Redis, Memcached)

In-memory key-value stores sitting between your application logic and your database. This layer handles computed results, session data, rate limiting counters, and any data that is expensive to regenerate. Redis dominates this space for good reasons — its data structures go far beyond simple key-value pairs.

Database Query Cache

Most databases have built-in query caching. MySQL’s query cache (deprecated in 8.0 for good reason), PostgreSQL’s shared buffers, and MongoDB’s WiredTiger cache all operate at this level. You typically tune these rather than configure them explicitly, and they serve as the last defense before hitting disk I/O.


Redis Patterns That Actually Work in Production

Redis is the workhorse of application-level caching. But how you use it matters enormously. Three patterns dominate production deployments, and choosing the wrong one creates subtle bugs that are painful to debug.

Cache-Aside (Lazy Loading)

The most common pattern. Your application checks Redis first. On a cache miss, it queries the database, writes the result to Redis, and returns the data. The application owns the caching logic entirely.

async function getUser(userId) {
  const cached = await redis.get(`user:${userId}`);
  if (cached) return JSON.parse(cached);

  const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
  await redis.set(`user:${userId}`, JSON.stringify(user), 'EX', 3600);
  return user;
}

When to use it: Read-heavy workloads where stale data is acceptable for a bounded period. This is the default choice for most applications.

The trap: Cache stampedes. When a popular key expires, hundreds of concurrent requests all miss the cache simultaneously and hammer your database. Mitigate this with probabilistic early expiration or mutex locks on cache population.

Write-Through

Every write operation updates both the cache and the database synchronously. The cache is always consistent with the database, eliminating stale reads at the cost of slower write operations.

When to use it: Data that is read far more frequently than it is written and where consistency matters — user profiles, configuration settings, permission tables.

The trap: You are now maintaining two write paths. If the Redis write succeeds but the database write fails (or vice versa), you have an inconsistency that is harder to detect than a simple cache miss.

Write-Behind (Write-Back)

Writes go to Redis immediately and are asynchronously flushed to the database. This dramatically improves write performance but introduces the risk of data loss if Redis crashes before the flush completes.

When to use it: High-throughput write scenarios like analytics counters, activity feeds, or leaderboards where losing a few seconds of data is acceptable.

TTL Strategy: The Art of Expiration

Time-to-live values are not arbitrary. They encode your tolerance for staleness. A few guidelines that have served me well:

  • User session data: 30 minutes with sliding expiration. Refresh TTL on every access.
  • API response cache: 60-300 seconds depending on volatility. Use shorter TTLs for data that changes frequently.
  • Computed aggregations: Match TTL to your reporting granularity. If dashboards refresh hourly, a 60-minute TTL is appropriate.
  • Configuration/feature flags: 5-15 seconds. Short enough to propagate changes quickly, long enough to absorb traffic.

Never set a TTL of zero or infinity in production. Every cached value should expire. If you think data should be cached forever, you are wrong — you just have not encountered the failure mode yet.


CDN Configuration: Cloudflare vs. CloudFront

CDN caching is where many teams either leave performance on the table or accidentally cache sensitive data. The two dominant players have meaningfully different configuration models.

Feature Cloudflare AWS CloudFront
Default caching behavior Caches based on file extension; respects origin headers Does not cache unless explicitly configured
Cache key customization Cache Rules (powerful, declarative) Cache Policies + Origin Request Policies
Purge speed Global purge in <30 seconds Invalidation takes 5-15 minutes
Edge compute Workers (V8 isolates, fast cold start) Lambda@Edge / CloudFront Functions
Cost model Flat-rate plans; unlimited bandwidth on paid tiers Pay per request + bandwidth; costs scale linearly
Cache analytics Built-in, real-time dashboard Requires CloudWatch or access logs
Stale-while-revalidate support Supported via Cache Rules Supported via cache policy

My recommendation: If you are running a content-heavy site or SaaS product and do not have deep AWS infrastructure commitments, Cloudflare’s developer experience and pricing model are hard to beat. CloudFront makes sense when your origin is in AWS and you need tight integration with S3, ALB, or API Gateway.

Regardless of provider, the critical configuration decisions are:

  • Cache key composition: What request attributes (query strings, headers, cookies) differentiate cached responses? Including too many attributes fragments your cache. Including too few serves wrong content to users.
  • Origin cache headers: Your CDN should respect Cache-Control headers from your origin. Do not fight your own headers with CDN overrides unless you have a specific reason.
  • Bypass rules: Always bypass cache for authenticated API endpoints, POST/PUT/DELETE requests, and any response that includes Set-Cookie headers.

Browser Cache Headers: Getting the Details Right

Browser caching is controlled through HTTP response headers. Getting these right eliminates unnecessary network requests entirely. Getting them wrong means users see stale content or your CDN never caches anything.

Cache-Control

The primary header. A few directives you need to understand deeply:

  • max-age=31536000, immutable — For fingerprinted static assets (JS, CSS, images with hashed filenames). The browser will never revalidate these. Combine with filename-based cache busting on deploy.
  • no-cache — Often misunderstood. This does not mean “do not cache.” It means “cache this, but revalidate with the origin before using it.” The browser stores the response but checks with the server on every request.
  • no-store — The actual “do not cache” directive. Use this for sensitive data: authentication responses, personal financial data, anything that should never touch a cache.
  • private — Cacheable by the browser but not by CDNs or shared proxies. Use for user-specific responses that are safe to cache locally.
  • s-maxage=600 — Overrides max-age for shared caches (CDNs) only. Lets you set aggressive browser caching while giving the CDN a shorter window.

ETag and Conditional Requests

ETags enable conditional requests. The server generates a hash of the response content. On subsequent requests, the browser sends If-None-Match with the stored ETag. If the content has not changed, the server responds with 304 Not Modified — no body, minimal bandwidth.

ETags are excellent for API responses and HTML pages where content changes unpredictably. They add a round trip but save bandwidth and server-side rendering time when content has not changed.

Watch out for: Weak ETags vs. strong ETags. Weak ETags (prefixed with W/) indicate semantic equivalence, not byte-for-byte identity. If you are using ETags for cache validation across a load-balanced fleet, make sure all servers generate identical ETags for identical content. This is where Nginx’s default ETag generation (based on modification time and content length) can bite you across multiple servers.

stale-while-revalidate

This directive is a game-changer for perceived performance. Cache-Control: max-age=60, stale-while-revalidate=300 tells the browser: serve the cached version immediately (even if stale), but fetch an updated version in the background for next time.

Users see instant responses. Content stays reasonably fresh. The tradeoff is that users occasionally see data that is up to five minutes old — acceptable for most content-driven applications, unacceptable for financial data or real-time collaboration.


Cache Invalidation: The Genuinely Hard Problem

There are only two hard things in Computer Science: cache invalidation and naming things. — Phil Karlton

This quote persists because it remains accurate. Invalidation is hard because caches are distributed, asynchronous, and layered. When you update data at the source, you need every cached copy across every layer to reflect that change — and there is no reliable broadcast mechanism that spans browsers, CDN edge nodes, and application caches.

Strategies That Work

  • TTL-based expiration: The simplest approach. Accept bounded staleness. Most applications can tolerate data being 30-60 seconds old. Set appropriate TTLs and stop worrying about manual invalidation.
  • Event-driven invalidation: When a write occurs, publish an event that triggers cache deletion across relevant layers. This works well with Redis pub/sub or a message queue like Kafka or SQS. The complexity is in ensuring every cache layer subscribes to the right events.
  • Versioned keys: Instead of invalidating user:123, increment a version counter and read from user:123:v7. Old versions expire naturally via TTL. This avoids race conditions during invalidation but increases memory usage.
  • Fingerprinted URLs: For static assets, embed a content hash in the filename (app.3f8a2b.js). New deploys produce new URLs. Old cached versions are never invalidated — they simply stop being referenced. This is the gold standard for static asset caching.

Strategies That Do Not Work

  • Manual purge buttons: If your operations team has a “clear all caches” button, something has gone wrong architecturally. Global purges cause thundering herd problems and are a symptom, not a solution.
  • Cache invalidation on every write: In write-heavy systems, this negates the benefit of caching entirely. If your invalidation rate approaches your read rate, caching is not solving your problem.

Common Mistakes That Burn Teams

After a decade of debugging production caching issues, these are the patterns I see repeated most often:

  • Caching error responses. Your application returns a 500 error, and your CDN caches it for 10 minutes. Thousands of users now see the error page. Always set Cache-Control: no-store on error responses, and configure your CDN to only cache 200-level responses.
  • Caching personalized content at the CDN layer. If your response includes a username, shopping cart count, or any user-specific data, it must not be cached by a shared cache. One user seeing another user’s data is a security incident, not a performance bug.
  • Ignoring Vary headers. If your response differs based on Accept-Encoding, Accept-Language, or custom headers, the Vary header tells caches to store separate copies. Omitting it means users may receive responses intended for a different client configuration.
  • No cache warming on deploy. You deploy a new version, all fingerprinted asset URLs change, and the CDN has zero cached copies. For the first few minutes, every request hits origin. Implement cache warming for critical assets immediately after deploy.
  • Treating Redis as durable storage. Redis is a cache. If you lose it, your application should degrade gracefully to database reads, not crash. If losing Redis data causes data loss, you are using Redis as a database, which is a different architectural decision with different operational requirements.

When NOT to Cache

Caching is not universally beneficial. There are scenarios where adding a cache layer makes things worse:

  • Write-heavy, read-light workloads: If data is written more frequently than it is read, cache invalidation overhead exceeds the benefit. Optimize your writes instead.
  • Highly personalized, real-time data: Live dashboards, collaborative editing, chat messages. These change constantly and per-user. Caching adds complexity without meaningful performance improvement.
  • Data with strict consistency requirements: Financial transactions, inventory counts during flash sales, anything where serving stale data has legal or financial consequences. Use database-level optimizations instead.
  • Small datasets that fit in memory: If your entire dataset is 50MB, PostgreSQL will keep it in shared buffers. Adding Redis in front of a well-tuned database query that already returns in 2ms adds latency and operational complexity for no measurable gain.
  • Development and staging environments: Caching in non-production environments masks bugs and makes debugging harder. Keep caching disabled or extremely short-lived in dev.

A Practical Decision Tree for Caching Strategies

When evaluating where and how to cache, walk through this sequence:

  1. Is the response identical for all users? If yes, cache aggressively at the CDN layer with long TTLs and fingerprinted URLs for assets.
  2. Does the response vary by a small number of dimensions (language, region, device type)? Cache at the CDN with appropriate Vary headers or cache key rules.
  3. Is the response user-specific but read-heavy? Cache in Redis with cache-aside pattern. Use Cache-Control: private for browser caching.
  4. Is the data expensive to compute but tolerant of staleness? Cache the computed result in Redis with a TTL matching your staleness tolerance. Consider stale-while-revalidate at the browser level.
  5. Is the data written frequently and must be fresh? Do not cache it. Optimize the read path at the database level instead.

This decision tree is deliberately simple. Most caching problems stem from teams jumping to a sophisticated solution before establishing whether caching is even appropriate for their specific access pattern.


The Bottom Line

Effective caching strategies for web applications are not about adding Redis to your stack and calling it a day. They require understanding the full request lifecycle, choosing the right cache layer for each type of data, setting appropriate TTLs, and — most critically — having a plan for invalidation before you write the first cache key.

Start with browser cache headers. They are free, require no infrastructure, and deliver the largest perceived performance improvement. Add CDN caching for public content. Reach for Redis when you have genuinely expensive computations or database queries that are read-heavy and tolerant of bounded staleness. At each layer, ask yourself what happens when the cached data is wrong — because eventually, it will be.

The teams that build fast, reliable applications are not the ones with the most caches. They are the ones who cache deliberately, invalidate predictably, and resist the temptation to cache their way out of problems that caching cannot solve.

By Michael Sun

Founder and Editor-in-Chief of NovVista. Software engineer with hands-on experience in cloud infrastructure, full-stack development, and DevOps. Writes about AI tools, developer workflows, server architecture, and the practical side of technology. Based in China.

Leave a Reply

Your email address will not be published. Required fields are marked *