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

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

HTTP caching, cache-aside, write-through, Redis patterns, and cache invalidation - plus the stale data problems. 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. 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. 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: 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. 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. 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. 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. 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. 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. 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): - Application receives a request for user 42's profile - Application checks the cache: GET user:42 - Cache hit โ†’ return the cached data (fast, ~1ms) - 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. 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. 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. 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. 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. Use when: most writes are not immediately re-read (user profile updates, settings changes). Comparison: | Strategy | Read | Write | Consistency | Data loss risk | |---|---|---|---|---| | Cache-aside | Fast (hit) / Slow (miss) | Fast | Eventual | No | | Write-through | Fast | Slow | Strong | No | | Write-behind | Fast | Fast | Eventual | Yes (crash before flush) | | Write-around | Fast (after first read) | Fast | Eventual | No | 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. The three invalidation strategies: 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). 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. 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: - Application reads from database (value = A) - Database is updated by another process (value = B) - Application writes A to cache (stale!) - 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. 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. Multi-Layer Caching Production systems rarely use a single cache. A typical architecture has multiple layers, each with different characteristics: | Layer | Technology | Latency | Scope | TTL | |---|---|---|---|---| | Browser cache | HTTP headers | 0ms | Single user | Hours/days | | CDN cache | Cloudflare, Fastly | ~10ms | Global edge | Minutes/hours | | Reverse proxy | NGINX, Varnish | ~1ms | Per-datacenter | Seconds/minutes | | Application cache | Redis, Memcached |

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.