Skip to main content
Cache Read Strategieslesson 1 of 4 · 3 min read

Cache-Aside

The pattern everybody writes first

Cache-aside is the pattern almost everybody writes first, and it stays the default for good reasons.

Your application does four things. It asks the cache for a key. On a hit it is done, usually in under a millisecond. On a miss it reads the database, writes the result into the cache with an expiry, and returns it.

Your cache never talks to your database. It is a dumb key-value store, and every piece of logic lives in your code.

That placement of logic is the whole trade. In your favour, you cache exactly what gets asked for and nothing else, so your memory only ever holds hot data.

Computed shapes work too, like a fully assembled profile rather than raw rows. And enjoy a gentle failure mode: if the cache dies, every request becomes a miss and falls through.

That assumption is worth testing before you need it, though. Slower but alive only holds if your database can absorb the load.

The minuses are yours too

The minuses are yours too. Every first request for a key pays full database latency, so a cold cache after a deploy makes for a bad ten minutes.

There is a race inside read-then-fill. Two requests miss at once, both read the database, both write, and the second one wins. For plain reads that is harmless duplication.

Pair it with an update at the wrong moment and you can cache a value that is already stale, which is why the companion rule is to delete the key on writes rather than update it.

One more habit worth stealing: always set an expiry, even a long one. An entry with no expiry plus one missed invalidation is a bug that lives forever.

the shape of it
App serverRedischeck firstPostgreson miss only1. GET key2a. hit: value2b. miss: query3. SET key + TTL
step 1 of 3
In cache-aside the application owns the miss path: read the database, fill the cache, return.
the pattern almost everybody writes first
Java
Product getProduct(long id) {
  String key = "product:" + id;

  Product hit = cache.get(key);
  if (hit != null) return hit;                 // 95% of calls stop here

  Product p = db.queryOne("SELECT * FROM products WHERE id = ?", id);
  cache.set(key, p, Duration.ofMinutes(5));    // always set an expiry
  return p;
}

// The companion rule: on a write, delete the key rather than
// updating it. An update races with a fill already in flight and
// can pin the old value; a delete just forces the next read to fetch.
void updateProduct(Product p) {
  db.save(p);
  cache.delete("product:" + p.id);
}

Worked example

Neha owns the product page service at a furniture retailer. Each page load ran three queries against Postgres, a relational database, totalling about 28 ms, and at 2,000 pages a second the primary was busy 85 percent of the time. She added cache-aside with Redis, an in-memory store: the key is product:{id}, the value is the rendered JSON, a text format for structured data, and the TTL, meaning how long an entry may live, is 5 minutes. Within an hour of rollout the hit rate settled at 96 percent. The database dropped to 12 percent busy, and the typical page, its p50, went from 31 ms to 2 ms. The one incident came two weeks later, when a deploy flushed the cache at peak and every request missed at once, pushing Postgres to 100 percent for 90 seconds. The fix, warming the top 10,000 product keys before flipping traffic over, went in the next sprint.