Module 3 / Caching

Module 3 · lesson 3 of 7 · Read 9 min

Cache-aside

The pattern behind most caches: check, miss, load, store.

We've decided to cache Tiffin's menus in Redis. Now the practical question: who puts the menu in the cache, and when?

The most common answer by far is a pattern called cache-aside. The cache sits to the side of the database, and your application code does all the work: check the cache, and if the answer isn't there, load it from the database and store it. If you've ever seen caching code in a real codebase, it was probably this.

Check, miss, load, store

Cache-aside: the application checks the cache first; on a miss, it reads from the database and puts the result in the cache for next time.

Step by step, for a request for restaurant 42's menu:

  1. Check. Ask Redis for menu:42.
  2. Hit? Return it. Done. The database is never touched.
  3. Miss? Run the menu query against the database.
  4. Store. Put the result in Redis under menu:42, with an expiry.
  5. Return it.
%% caption: The read path of cache-aside. Only misses reach the database.
flowchart TD
  R([Request for menu 42]) --> C{"menu:42 in Redis?"}:::hot
  C -- hit --> OUT([Return it])
  C -- miss --> D[("Postgres: menu query")]:::store
  D --> S["Store in Redis, with an expiry"]
  S --> OUT
The read path of cache-aside. Only misses reach the database.

Here's the same thing as a request flow, the way you'd draw it on a whiteboard: one customer misses, the next one hits.

%% caption: Two requests for the same menu. Only the first one costs a database query.
sequenceDiagram
  autonumber
  participant P as Phone
  participant A as App server
  participant R as Redis
  participant D as Postgres
  P->>A: GET /restaurants/42/menu
  A->>R: GET menu:42
  R-->>A: nothing (miss)
  A->>D: menu query, ~25 ms
  D-->>A: rows
  A->>R: SET menu:42, with expiry
  A-->>P: menu
  Note over P,D: A second customer opens the same menu
  P->>A: GET /restaurants/42/menu
  A->>R: GET menu:42
  R-->>A: menu, under 1 ms
  A-->>P: menu
Two requests for the same menu. Only the first one costs a database query.

The cache fills itself lazily: only menus someone actually asks for get cached. The 3,000th most popular restaurant, opened twice a day, won't take up space until someone wants it.

In code

Here's the whole read path in JavaScript, using a typical Redis client:

const MENU_TTL_SECONDS = 600

async function getMenu(restaurantId) {
  const key = `menu:v1:${restaurantId}`

  const cached = await redis.get(key)
  if (cached !== null) {
    return JSON.parse(cached)
  }

  const menu = await loadMenuFromDb(restaurantId)   // the 25 ms query
  await redis.set(key, JSON.stringify(menu), { EX: MENU_TTL_SECONDS })
  return menu
}

That's it. Ten lines, and most of Tiffin's menu load stops reaching the database. A few details in it are worth a closer look.

The key

menu:v1:42 follows a simple convention: what it is, a version, which one. The colon-separated style is a Redis habit, and it makes keys easy to read in tools and logs.

The v1 is there for the day you change the shape of the cached menu. Say you add a spice_level field to every dish. Old cached menus don't have it, and new code might crash reading them. Bump the key to v2, deploy, and new code simply never sees old entries. They expire on their own.

The key must include everything the answer depends on. If menus differ by language, the key must include it: menu:v1:42:hi. Forget, and Hindi speakers get the English menu, or the other way round, depending on who asked first. This is one of the most common caching bugs there is.

The value

We store the finished JSON, ready to send. Caching the final answer rather than raw rows means a hit does almost no work: no joins, no grouping into categories, just return the string.

A Tiffin menu is about 30 KB of JSON. 3,000 restaurants x 30 KB = 90 MB if every single menu were cached at once. That fits comfortably in a small Redis server.

The expiry

Every key gets an expiry, called a TTL (time to live). After 600 seconds Redis deletes it, and the next request reloads it from the database.

Even though we'll also delete the key whenever the menu changes (next section), the TTL is a safety net. If an invalidation is ever missed (a bug, a crash at the wrong moment, someone editing the database by hand), the wrong data lives at most 10 minutes instead of forever. Lesson 5 is all about choosing TTLs.

The write path: update, then delete

Cache-aside covers reads. What happens when a restaurant changes its menu?

The usual rule is: write to the database, then delete the cache key.

async function updateMenuItem(itemId, changes) {
  const item = await db.updateMenuItem(itemId, changes)   // the truth changes first
  await redis.del(`menu:v1:${item.restaurantId}`)          // then drop the copy
  return item
}

The next request for that menu misses, reloads the fresh menu from the database, and caches it again. We don't try to edit the cached menu in place, and we don't write the new menu into the cache ourselves. Deleting is simpler and avoids some nasty race conditions; we'll look at why in the next lesson.

What if Redis is down?

Redis is now a dependency of every menu request. What should happen when it's slow or unreachable?

The cache is an optimisation, not the source of truth. So for reads, the right default is: if the cache fails, fall back to the database.

async function getMenu(restaurantId) {
  const key = `menu:v1:${restaurantId}`

  let cached = null
  try {
    cached = await redis.get(key)   // client set up with a ~50 ms timeout (see below)
  } catch (err) {
    log.warn('cache read failed', { key, err })
  }
  if (cached !== null) return JSON.parse(cached)

  const menu = await loadMenuFromDb(restaurantId)
  redis.set(key, JSON.stringify(menu), { EX: 600 }).catch(() => {})  // best effort
  return menu
}

Two things changed:

  • A short timeout. Redis normally answers in half a millisecond. If it hasn't answered in 50 ms, something is wrong, and waiting longer just makes every request slow. Better to treat it as a miss. (Most Redis clients set this timeout once, when you create the client, rather than on each call.)
  • Storing is best effort. If writing to the cache fails, the customer still gets their menu.

But be honest about what that fallback means at dinner time. With a 95% hit ratio, the database is only doing 5% of the menu work. If Redis disappears, it suddenly gets 100%: 20 times its usual menu load, at the busiest moment. It may not survive. Lesson 6 covers how to protect the database when the cache fails.

On the write path, a failed delete is more serious: the cache keeps the old menu until the TTL runs out. Log it loudly, retry it, and keep the TTL short enough that the worst case is acceptable.

Read-through: the same idea, packaged

You'll also hear of read-through caching. It's the same check-miss-load-store logic, but done by the cache layer instead of your code. You give a caching library a "loader" function, and from then on you just ask the cache:

// ReadThroughCache stands for whatever caching library or helper you use
const menus = new ReadThroughCache({ ttl: 600, load: loadMenuFromDb })
const menu = await menus.get(42)   // loads from the database on a miss, automatically

The behaviour is identical. The difference is where the logic lives. Read-through keeps the pattern in one place, so every caller gets the same timeouts, key format and error handling. Cache-aside is more explicit and needs no library. Many teams start with cache-aside and wrap it into a small helper once they have more than a few cached things, which is read-through in all but name.

Strengths and weaknesses

Cache-aside
Only caches what's actually read Yes, lazily
Survives cache failure Yes, falls back to the database
Works with any database and any cache Yes, it's just your code
First request after a change or expiry Slow (a miss)
Can serve stale data Yes, if a delete is missed or races with a read
Code to write A little in every place that reads or writes cached data

It's the default for good reasons. It's simple, it fails safely, and it puts you in control.

Find the bugs

A teammate writes this to cache the list of open restaurants near a customer. Find at least three problems.

async function nearbyRestaurants(lat, lng, customerId) {
  const key = `nearby:${customerId}`
  const cached = await redis.get(key)
  if (cached) return JSON.parse(cached)

  const list = await db.openRestaurantsNear(lat, lng)
  await redis.set(key, JSON.stringify(list))
  return list
}
Show one answer
  1. The key is wrong. It's keyed by customer, but the answer depends on location, not the customer. A customer who opens the app at work and then at home sees their work restaurants at home. And every customer gets their own entry, so customers in the same area never share a hit. Better: round the location to a small area (say a ~1 km grid cell) and key by that: nearby:v1:<cell id>.
  2. No TTL. Restaurants open and close through the day. Without an expiry, the list is wrong forever. Add something like EX: 60.
  3. No version in the key. The day the cached shape changes, old entries will break new code.
  4. No error handling or timeout. If Redis is down or slow, this throws or hangs instead of falling back to the database.
  5. if (cached) is a fragile miss check. It works here because Redis returns strings, but the habit breaks the day a cached value is an empty string or a client returns parsed values like 0. Compare with null explicitly.

What to remember

  1. Cache-aside: check the cache; on a miss, load from the database, store the result with a TTL, and return it.
  2. The cache fills lazily, with only what's actually read. Store the finished answer, not raw rows.
  3. Keys include everything the answer depends on, plus a version you can bump when the format changes.
  4. On writes: update the database first, then delete the cache key. Don't edit cached values in place.
  5. Treat cache failures as misses with a short timeout, but remember the database must then carry the full load.
  6. Read-through is the same pattern moved into a cache layer or helper.