notAcalculator logo

Caching Strategies Explained: From Browser to Database, and the Stale Data in Between

Caching strategies every developer should know: HTTP caching, cache-aside, write-through, write-behind, Redis patterns, cache invalidation, and the stale data problems that keep systems up at night.

The Request That Cost $100,000

In 2017, a major cloud provider experienced an outage that traced back to a single misconfigured cache. A caching layer that was supposed to serve data within 5 milliseconds had been configured with a TTL (time-to-live) of 24 hours. When the underlying database was updated at 2:00 PM, the cache continued serving the old data until 2:00 PM the next day. Customers saw stale pricing. Orders were placed at outdated rates. The company had to honor the incorrect prices for 12 hours of transactions — a six-figure loss caused by a single configuration value[redis-patterns].

Caching is the most powerful performance tool in a developer's arsenal. It can make a system 100× faster, reduce database load by 95%, and turn a $10,000 database server into a $200 one. But caching is also the most dangerous tool, because it introduces a fundamental problem that no amount of engineering can eliminate: stale data. The cache and the database are two copies of the same truth, and keeping them in sync is one of the hardest problems in distributed systems[rfc7234].

This guide explains how to use caching effectively: the strategies that work, the patterns that prevent stale data, and the trade-offs that determine whether your cache is helping or hurting. Whether you're adding a simple HTTP cache header or designing a multi-layer caching architecture, the same principles apply.

The Two Hard Problems in Caching

Every caching decision comes down to two questions:

  1. What do I cache? — The data that's read frequently but changes infrequently is the ideal candidate. User profiles, product catalogs, configuration settings, and rendered HTML fragments are all good candidates. Data that changes every second (stock prices, real-time sensor readings) is usually not worth caching.

  2. How long do I keep it? — The TTL (time-to-live) determines the maximum staleness. A TTL of 5 seconds means a user might see data that's up to 5 seconds old. A TTL of 24 hours means they might see yesterday's data. The right TTL depends on the business: a news site might tolerate 5-minute-old headlines; a banking app must show real-time balances.

The tension is direct: longer TTL = better performance, worse freshness. A cache with a 1-hour TTL serves almost every request from memory (fast) but can show data that's up to an hour old (stale). A cache with a 1-second TTL is almost always fresh but provides almost no performance benefit because the cache expires before most users return[rfc7234].

The cache hit ratio measures how often the cache actually helps. A hit ratio of 95% means 95% of requests are served from the cache, and only 5% reach the database. This is the single most important metric for cache effectiveness. A cache with a 50% hit ratio is barely helping — half your requests still hit the database.

HTTP Caching: The First Line of Defense

The simplest and most effective caching happens at the HTTP level, before your application code even runs. Browsers, CDNs, and reverse proxies (NGINX, Varnish, Cloudflare) can cache HTTP responses based on headers your server sends[rfc7234].

The two most important headers:

Cache-Control tells caches how to handle the response:

  • max-age=3600 — cache for 1 hour (3600 seconds)
  • no-cache — always revalidate with the server before using the cached copy
  • no-store — never cache at all (sensitive data)
  • public — any cache can store this (CDNs, proxies)
  • private — only the user's browser can cache this (not shared caches)

ETag (entity tag) provides a version identifier. When the cache expires, the browser sends the ETag back to the server. If the data hasn't changed, the server responds with 304 Not Modified (no body), saving bandwidth. If it has changed, the server sends the new data with a new ETag[rfc7234].

Example — a product catalog page:

Cache-Control: public, max-age=300
ETag: "abc123"

This tells every cache between your server and the user: "Cache this for 5 minutes. After that, check with the server using ETag 'abc123' before using the cached version." The result: users see a page that's at most 5 minutes old, and your server only handles requests from users who haven't visited in the last 5 minutes[google-web-caching].

The stale-while-revalidate pattern extends this: serve the cached version immediately (even if expired) while fetching a fresh version in the background. Users always get a fast response, and the cache stays fresh without blocking requests. This is how CDNs like Cloudflare and Fastly achieve both speed and freshness[google-web-caching].

Application-Level Caching: Cache-Aside

HTTP caching handles static and semi-static content. For dynamic data — user sessions, personalized recommendations, computed results — you need application-level caching, usually with Redis or Memcached.

The most common pattern is cache-aside (also called lazy loading)[azure-cache-aside]:

  1. Application receives a request for user 42's profile
  2. Application checks the cache: GET user:42
  3. Cache hit → return the cached data (fast, ~1ms)
  4. Cache miss → query the database (~50ms), store the result in cache with a TTL, return the data

The cache-aside pattern is simple and robust. The downside: the first request for any data is always a cache miss, and if the database is slow, that first request is slow. Also, if the data changes in the database, the cache still holds the old value until the TTL expires — the staleness problem again[azure-cache-aside].

Cache-aside pseudocode:

function getUser(id):
    cached = redis.get("user:" + id)
    if cached != null:
        return cached
    user = db.query("SELECT * FROM users WHERE id = ?", id)
    redis.setex("user:" + id, 3600, user)  // TTL 1 hour
    return user

The thundering herd problem. When a popular cache key expires, hundreds of simultaneous requests all miss the cache and hit the database at once. The database buckles. The fix: use a lock (mutex) so only one request fetches from the database while others wait and then read from the now-populated cache. Redis's SETNX (set-if-not-exists) is commonly used for this[redis-patterns].

Write Strategies: When the Cache Updates

Cache-aside handles reads. For writes, there are three main strategies:

Write-Through

Every write goes to the cache and the database simultaneously (or the cache writes to the database synchronously). The cache is always up-to-date, but writes are slower because they wait for both systems[redis-patterns].

Use when: consistency is more important than write speed (banking, inventory, booking systems).

Write-Behind (Write-Back)

Writes go to the cache immediately, and the cache asynchronously flushes changes to the database in batches. Writes are fast, but there's a window where the cache and database disagree. If the cache crashes before flushing, data is lost[redis-patterns].

Use when: write speed matters more than perfect consistency (analytics, logging, metrics, social media likes).

Write-Around

Writes go directly to the database, bypassing the cache. The cache is only populated on the next read (cache-aside). This avoids polluting the cache with data that may not be read again soon[azure-cache-aside].

Use when: most writes are not immediately re-read (user profile updates, settings changes).

Comparison:

Cache-asideFast (hit) / Slow (miss)FastEventualNo
Write-throughFastSlowStrongNo
Write-behindFastFastEventualYes (crash before flush)
Write-aroundFast (after first read)FastEventualNo

Cache Invalidation: The Hardest Problem

Phil Karlton famously said, "There are only two hard things in computer science: cache invalidation and naming things." The joke is that it's not a joke. Cache invalidation — ensuring the cache reflects the current truth — is genuinely difficult because the cache and the database are separate systems with no atomic coordination[martin-fowler-cqrs].

The three invalidation strategies:

  1. TTL-based (time-to-live). Set an expiration time. Simple, but data can be stale for up to the TTL duration. Good for data that changes predictably (news feeds, trending lists).

  2. Event-based (write-through). When the database changes, immediately update or delete the corresponding cache entry. More complex, but keeps the cache fresh. Requires application code to know which cache keys to invalidate when data changes.

  3. Version-based. Include a version number in the cache key: user:42:v3. When the data changes, increment the version. Old versions expire naturally via TTL. This avoids the "delete the cache entry" race condition but requires the application to know the current version.

The race condition that breaks naive invalidation:

  1. Application reads from database (value = A)
  2. Database is updated by another process (value = B)
  3. Application writes A to cache (stale!)
  4. Application invalidates cache — but the invalidation arrives AFTER the stale write

The fix: use a cache invalidation queue (e.g., Redis pub/sub, Kafka) so invalidations are processed in order, or use version-based keys that make the race impossible[redis-patterns].

Cache stampede (thundering herd). When a popular cache key expires, hundreds of simultaneous requests all miss the cache and hit the database at once. The database buckles under the sudden load. The fix: use a lock (mutex) so only one request fetches from the database while others wait and then read from the now-populated cache. Redis's SETNX (set-if-not-exists) is commonly used for this — the first request to acquire the lock fetches from the database; subsequent requests either wait or serve slightly stale data[redis-patterns].

Multi-Layer Caching

Production systems rarely use a single cache. A typical architecture has multiple layers, each with different characteristics:

Browser cacheHTTP headers0msSingle userHours/days
CDN cacheCloudflare, Fastly~10msGlobal edgeMinutes/hours
Reverse proxyNGINX, Varnish~1msPer-datacenterSeconds/minutes
Application cacheRedis, Memcached~1msPer-instanceMinutes/hours
Database cachePostgreSQL buffer pool~0.1msPer-instanceAutomatic

The key insight: each layer serves a different purpose. The browser cache eliminates network round-trips for returning users. The CDN cache serves static assets from edge locations close to users. The application cache reduces database load for dynamic data. The database cache keeps frequently accessed data in memory[aws-caching].

Cache hierarchy rule: a request should be served by the closest (fastest) layer that has the data. If the browser has it, don't hit the CDN. If the CDN has it, don't hit the origin. If the application cache has it, don't query the database. Each layer should fall through to the next only on a miss.

Practical Tips for Caching

  1. Start with HTTP caching. Before adding Redis, make sure you're using Cache-Control and ETag headers correctly. This alone can reduce server load by 80% for read-heavy applications.

  2. Measure your hit ratio. A cache with < 50% hit ratio is barely helping. Investigate why — maybe the TTL is too short, or the cache keys are too specific.

  3. Use a small TTL for user-specific data. User sessions, preferences, and dashboards should have short TTLs (30 seconds to 5 minutes) to avoid showing stale personal data.

  4. Use a large TTL for shared data. Product catalogs, configuration, and reference data can have long TTLs (1 hour to 1 day) because they change infrequently.

  5. Implement cache warming. Pre-populate the cache on startup or after a deploy so the first users don't suffer cache misses. A simple script that queries the most popular items and stores them in Redis prevents the "cold start" problem.

  6. Monitor cache memory usage. Redis and Memcached evict data when memory is full. If your hit ratio drops suddenly, check whether the cache is evicting keys due to memory pressure. Use allkeys-lru or volatile-lru eviction policies to keep the most recently used data[redis-patterns].

  7. Don't cache everything. Data that's written once and read once (event logs, audit trails) doesn't benefit from caching. Data that changes every second (real-time sensor data) can't be cached effectively. Focus on data that's read many times between writes.

Limitations and When Not to Cache

Caching is not a silver bullet. It adds complexity: another system to monitor, another failure mode, another source of bugs. Don't cache when:

  • Traffic is too low. If your site gets 100 requests per day, a cache adds complexity without meaningful benefit. The database can handle that load directly.
  • Data is highly personalized. If every user sees a completely different page, the cache hit ratio will be near zero. Cache the shared parts (header, footer, CSS) and fetch personalized parts from the database.
  • Consistency is critical. If showing stale data for even a second is unacceptable (financial transactions, medical records), caching may be the wrong tool. Use the database directly and optimize it instead (read replicas, connection pooling, query optimization).
  • Write-heavy workloads. If your application writes far more than it reads, the cache will be constantly invalidated, providing little benefit while consuming memory and CPU.

Frequently Asked Questions

What is cache-aside?
The most common caching pattern: check the cache first, and on a miss, fetch from the database and store the result in the cache. Simple and robust, but the first request for any data is always a cache miss.
What is the difference between write-through and write-behind?
Write-through writes to cache and database simultaneously (slow writes, strong consistency). Write-behind writes to cache first, then asynchronously flushes to the database (fast writes, eventual consistency, risk of data loss on crash).
How do I choose a TTL?
It depends on how stale the data can be. User-specific data: 30 seconds to 5 minutes. Shared reference data: 1 hour to 1 day. Static assets: days to weeks. The shorter the TTL, the fresher the data but the lower the cache hit ratio.
What is cache invalidation?
The process of ensuring the cache reflects the current truth. Three main strategies: TTL-based (expire after a time), event-based (update cache when data changes), and version-based (include a version in the cache key). All have trade-offs between consistency and complexity.
What is a cache hit ratio?
The percentage of requests served from the cache rather than the database. A hit ratio of 95% means 95% of requests never reach the database. Below 50%, the cache is barely helping.
What is the thundering herd problem?
When a popular cache key expires, hundreds of simultaneous requests all miss the cache and hit the database at once, potentially overwhelming it. Fix with a lock (mutex) so only one request fetches from the database while others wait.
Should I use Redis or Memcached?
Redis is more versatile: it supports data structures (hashes, lists, sets), pub/sub, persistence, and Lua scripting. Memcached is simpler and slightly faster for pure key-value caching. For most applications, Redis is the better choice.
How do I handle cache consistency in a microservices architecture?
Use event-driven invalidation: when a service updates data, it publishes an event (e.g., to Kafka or Redis pub/sub), and other services invalidate their cache entries in response. This keeps caches eventually consistent without tight coupling.

References

  1. [1]Fielding, Roy T., and Julian F. Reschke. "Hypertext Transfer Protocol (HTTP/1.1): Caching." RFC 7234, 2014.
  2. [2]Redis. Patterns and Use Cases.
  3. [3]Google Developers. HTTP Caching.
  4. [4]Microsoft Azure. Cache-Aside Pattern.
  5. [5]AWS. Caching.
  6. [6]Fowler, Martin. "CQRS" (Command Query Responsibility Segregation). 2011.
Give us your feedback! Was this useful?
1b

UnByte — Independent Software Engineering

All reference data cites its sources — Editorial policy