System Design ยท 3 of 5
Why a database that handled 10K requests a minute falls over at 10:01, and how cache write policies, eviction strategies, and distributed caching failure modes actually get chosen in production.
You're on-call. It's launch day for a flash sale, and at 10:00am your monitoring dashboard goes from green to a wall of red in about ninety seconds. Database CPU is pinned at 100%. Response times went from 40ms to 8 seconds. Support tickets are piling up. Nobody added more traffic than expected โ the database was just never built to answer the same "what's the price of this product" question 50,000 times a second.
This is the problem caching solves. Not "make things faster" in the abstract โ specifically, stop asking the database the same question over and over when the answer hasn't changed.
Here's the part that surprises people who haven't had to debug this: disk-backed reads (even on a well-tuned Postgres instance) usually cost single-digit milliseconds. Reading the same value from memory costs sub-millisecond. That gap doesn't sound huge until you multiply it by tens of thousands of requests per second โ at that point, the database isn't slow because of bad queries, it's slow because it's doing redundant work that a cache layer could have absorbed.
[ Client ] ---> [ Application Server ] ---> [ Cache (Redis) ]
| (Cache Miss)
v
[ Database (PostgreSQL) ]
Once you accept that you need a cache, the next question isn't "should I cache" โ it's "how do I keep the cache honest." That's where write policies come in.
Every write policy is really answering one question: when data changes, who updates the cache, and when? Get this wrong and you'll serve stale prices, stale inventory counts, or stale account balances โ which is a much worse failure than being slow.
The application owns both reads and writes explicitly.
This is the default choice for most teams, and for good reason โ it fails safely. If your cache layer goes down at 3am, cache-aside degrades to "slow" instead of "broken."
The cache becomes the interface your application writes to; the cache is responsible for keeping the database in sync.
Use this when correctness matters more than write speed โ session state, feature flags, anything where a stale read causes a real bug.
Same idea as write-through, except the database write happens asynchronously.
I've seen teams pick write-back for view counts and like counts โ losing a few seconds of counter increments during a crash is an acceptable trade for the write throughput it buys. Nobody would make that same trade for payment records.
Memory isn't free, so eventually the cache fills up and has to decide what to throw away. This decision matters more than it sounds โ pick the wrong policy and you end up with cache thrashing, where the same items get evicted and immediately re-fetched, and the cache stops helping at all.
If your hit rate suddenly drops during peak traffic (not before it), thrashing is usually the first thing to check โ not a capacity problem.
| Eviction Policy | Description | Best For |
|---|---|---|
| LRU (Least Recently Used) | Evicts the item that has not been accessed for the longest time. | General-purpose workloads with temporal locality. |
| LFU (Least Frequently Used) | Evicts the item with the lowest access count. | Static files or search results with stable popularity. |
| FIFO (First In First Out) | Evicts the oldest item based on insertion time. | Simple queues or time-sequenced batch data. |
An LRU cache is typically implemented using a combination of a Hash Map (for $O(1)$ lookup) and a Doubly Linked List (for $O(1)$ updates to the access order).
// A conceptual LRU update workflow
function get(key) {
if (!map.has(key)) return null;
const node = map.get(key);
moveToHead(node); // Mark as most recently used
return node.value;
}
When scaling horizontally across multiple machines, local memory caches (in-memory maps) become problematic because each server has a different view of the data.
To solve this, we use a distributed caching cluster like Redis or Memcached.
Occurs when a large portion of the cache expires simultaneously, causing a sudden surge of traffic to hit the database.
Occurs when a popular cached item expires, and multiple parallel requests attempt to recalculate the same item and write it to the database at the same time.
Occurs when requests query keys that do not exist in the database (e.g., malicious requests for non-existent IDs, or a bug that generates random IDs). Since the key is never found in the cache or the database, every single one of those requests hits the database directly โ the cache provides zero protection.
Caching solves the "don't ask twice" problem for reads. But once you have multiple services that need to react to the same event โ an order being placed, a payment clearing โ polling a cache doesn't scale. That's where event streaming systems like Kafka come in, which is the next chapter.
Get an email when I publish the next chapter. No spam โ just new technical deep-dives.
Comments
Share feedback or questions about this blog post.
No comments yet. Be the first to share your thoughts.