Skip to main content

Codelab: Cache a Slow Endpoint with Redis

What You'll Build

Eight lessons of caching theory are about to become numbers in your terminal. In the next twenty minutes you'll build a product API with a deliberately slow database (~200 ms per query), watch it crawl, then bolt Redis cache-aside in front of it and watch the same endpoint answer in 1.5 milliseconds — then break it, twice, on purpose.

By the end you'll have personally produced: a ~140× speedup on a hot read, a measured 95% hit rate straight out of INFO stats, a correct delete-on-write invalidation, a cache caught lying about a price until its TTL executed it, and an app that survived its cache dying — including the sneaky production lesson of how long a dead cache can hold a request hostage.

Everything below is a real session — every output shown is what actually came back. You need Node (any recent version) and about twenty minutes. No Docker required.

A codelab overview infographic titled 'Cache a slow endpoint with Redis - and watch every number from this chapter appear in your own terminal.' The centre shows the system you build: curl on the left sends requests to a small Node server, which consults a Redis box holding product:1 with a thirty-second TTL before falling back to a deliberately slow database that takes two hundred milliseconds. Above the flow, a before-and-after latency bar makes the payoff visceral: without the cache every request is about two hundred twelve milliseconds and twenty requests take four point three seconds; with cache-aside the first miss costs two hundred twelve milliseconds, every hit costs one point five milliseconds - roughly one hundred forty times faster - and the same twenty requests take zero point one seconds, with Redis reporting twenty-one hits and one miss, a ninety-five percent hit rate. Below, three small panels preview the experiments: proper invalidation, where a price write updates the database then deletes the key and the next read misses and refetches fresh; break-it number one, where a sneaky write updates the database but forgets the cache, so the cache confidently serves the old price until its TTL runs out; and break-it number two, where Redis is killed entirely and the app still answers - but the first attempt hangs for ten seconds until a fifty-millisecond timeout budget makes a dead cache cost almost nothing. A strip of command chips lists the tools used: brew install redis, redis-cli, SETEX, TTL, INFO stats, curl, and node. The palette is dark slate with emerald hits, red misses and lies, and an amber Redis box.

Setup: Get Redis Talking

Install Redis with your package manager (macOS shown; on Debian/Ubuntu it's sudo apt install redis-server, or run the redis Docker image if you prefer). Then start it as a background daemon and poke it:

brew install redis
redis-server --daemonize yes
redis-cli ping

Redis answers the only way it knows how:

PONG

Before wiring it to an app, feel the two primitives this whole chapter runs on — a key-value SET/GET, and a TTL (the shelf life from the invalidation lesson):

$ redis-cli set greeting "hello cache"
OK
$ redis-cli get greeting
"hello cache"

$ redis-cli setex temp:otp 30 "739114"     # SET with a 30-second lifespan
OK
$ redis-cli ttl temp:otp
(integer) 30
$ redis-cli ttl temp:otp                   # a few seconds later…
(integer) 27

That countdown is a value dying in real time. In thirty seconds temp:otp will simply cease to exist — no cleanup code, no cron. Hold that thought; it becomes our safety net in the first break-it.

Step 1: The Slow Endpoint (Feel the Pain)

Here's the app we're rescuing — a product endpoint backed by a deliberately slow database. The 200 ms setTimeout stands in for a real analytics-grade query with a few joins; everything else is plain Node, no frameworks. Save as server.js:

// server.js — a product endpoint backed by a deliberately SLOW "database"
const http = require('http');

// our fake database: one product, and a query that takes ~200 ms (a realistic slow join)
const db = { 1: { id: 1, name: 'Air Jordan 1 Retro', price: 220 } };
async function slowDbQuery(id) {
  await new Promise((r) => setTimeout(r, 200)); // the expensive part
  return db[id];
}

http
  .createServer(async (req, res) => {
    const m = req.url.match(/^\/product\/(\d+)$/);
    if (!m) { res.writeHead(404); return res.end('not found\n'); }
    const product = await slowDbQuery(m[1]);
    res.writeHead(200, { 'content-type': 'application/json' });
    res.end(JSON.stringify(product) + '\n');
  })
  .listen(4000, () => console.log('listening on :4000'));

Run it (node server.js) and hit it a few times — curl -w prints the total request time:

$ curl -s -w "→ %{time_total}s\n" localhost:4000/product/1
{"id":1,"name":"Air Jordan 1 Retro","price":220}
→ 0.212360s

$ curl -s -w "→ %{time_total}s\n" localhost:4000/product/1     # the SAME request again
{"id":1,"name":"Air Jordan 1 Retro","price":220}
→ 0.203008s

Same product, same answer, same 200 ms — the database dutifully re-runs the identical query every single time. Twenty requests in a row:

$ time ( for i in $(seq 1 20); do curl -s localhost:4000/product/1 > /dev/null; done )
4.331 total

4.3 seconds to serve one unchanging product twenty times. That's the shape of every uncached read-heavy endpoint on Earth — and the remember the answer idea from the start of this chapter is about to delete almost all of it.

Step 2: Add Cache-Aside (Three Steps, Verbatim)

Now the pattern from the caching-patterns lesson, literally transcribed: ask the cache → on a miss, ask the DB → write the answer back with a TTL. Install the client (npm install redis) and replace the handler's core with this — note the x-cache header so we can see hits and misses:

const { createClient } = require('redis');
const redis = createClient(); // localhost:6379
redis.on('error', () => {}); // if Redis is down, we fall back to the DB
redis.connect().catch(() => {});

async function getProduct(id) {
  const key = `product:${id}`;
  try {
    const cached = await redis.get(key); // 1. ask the cache
    if (cached) return { product: JSON.parse(cached), from: 'HIT' };
  } catch {} // cache down? no problem — keep going
  const product = await slowDbQuery(id); // 2. miss → ask the slow DB
  try {
    await redis.setEx(key, 30, JSON.stringify(product)); // 3. fill the cache (TTL 30 s)
  } catch {}
  return { product, from: 'MISS' };
}

// …and in the request handler:
const { product, from } = await getProduct(m[1]);
res.writeHead(200, { 'content-type': 'application/json', 'x-cache': from });

Restart the server and repeat the experiment:

$ curl -s -i localhost:4000/product/1        # first request — cold cache
x-cache: MISS
{"id":1,"name":"Air Jordan 1 Retro","price":220}
time: 0.212100s

$ curl -s -i localhost:4000/product/1        # same request again
x-cache: HIT
{"id":1,"name":"Air Jordan 1 Retro","price":220}
time: 0.001532s

Read those two numbers again: 212 ms → 1.5 ms. The hit is roughly 140× faster, because it never touches the database at all — it's one RAM lookup away, exactly the latency-ladder jump the first caching lesson promised. And the twenty-request loop:

$ time ( for i in $(seq 1 20); do curl -s localhost:4000/product/1 > /dev/null; done )
0.104 total

4.331 s → 0.104 s. Same endpoint, same answers — the work simply stopped being repeated.

Step 3: Read the Score Like an Operator

Don't take the speedup on vibes — Redis keeps score. INFO stats carries the two counters that define this entire chapter:

$ redis-cli info stats | grep keyspace
keyspace_hits:21
keyspace_misses:1

Twenty-one hits, one miss — a 95% hit rate (21 ÷ 22), measured, not estimated. And now the effective-latency formula from Why Caching Wins gets real numbers plugged into it:

effective latency ≈ 0.95 × 1.5 ms + 0.05 × 212 ms ≈ 12 ms average — dominated, exactly as promised, by the misses.

You can also inspect the copy itself — it's just a key, with the shelf life we gave it:

$ redis-cli get product:1
"{\"id\":1,\"name\":\"Air Jordan 1 Retro\",\"price\":220}"
$ redis-cli ttl product:1
(integer) 30

One habit to take to production: watch the hit rate, not the latency. Latency is the symptom; hit rate is the cause — and keyspace_hits/keyspace_misses is where it lives.

Step 4: Change the Price Without Lying

The seller cuts the price. Time to run the invalidation lesson's two rules for real — database first, then DELETE the key (never update it). The write path:

// the WRITE path: update the price → DB first, THEN delete the cache key
if (m && req.method === 'POST') {
  db[m[1]].price = Number(m[2]);          // 1. write the database (the truth)
  await redis.del(`product:${m[1]}`);     // 2. delete the copy — never update it
  res.end(JSON.stringify(db[m[1]]) + '\n');
}

Fire it, then read twice:

$ curl -s -X POST localhost:4000/product/1/price/189
{"id":1,"name":"Air Jordan 1 Retro","price":189}

$ curl -s -i localhost:4000/product/1        # next read
x-cache: MISS
{"id":1,"name":"Air Jordan 1 Retro","price":189}

$ curl -s -i localhost:4000/product/1        # and again
x-cache: HIT
{"id":1,"name":"Air Jordan 1 Retro","price":189}

Textbook: the delete forced one honest MISS that pulled the fresh $189 from the database, and every read after that is a fast HIT on the truth. One extra database trip — that's the entire price of freshness when the write path does its job. Now let's see what happens when it doesn't.

Break It #1: The Write That Forgets the Cache

Every real codebase eventually grows a write path that someone forgot to wire to the cache — an admin tool, a bulk import, a script. Let's build that bug honestly: a price-sneaky route that updates the database and doesn't touch Redis. The cache is warm at 189;thesneakywritedropstherealpriceto189**; the sneaky write drops the real price to **149:

$ curl -s -X POST localhost:4000/product/1/price-sneaky/149
{"id":1,"name":"Air Jordan 1 Retro","price":149}      # the DATABASE now says $149

$ curl -s -i localhost:4000/product/1        # what do users see?
x-cache: HIT
{"id":1,"name":"Air Jordan 1 Retro","price":189}      # …the cache says $189. It is LYING.

$ redis-cli ttl product:1
(integer) 15

There it is — the second-hardest problem live in your terminal: the database holds the truth, the cache confidently serves the lie, and nothing is broken enough to alert anyone. But look at that TTL: the lie has 15 seconds to live. Wait it out:

$ sleep 31   # let the TTL execute

$ curl -s -i localhost:4000/product/1        # after the TTL died
x-cache: MISS
{"id":1,"name":"Air Jordan 1 Retro","price":149}      # fresh from the DB. Self-healed.

That's why the invalidation lesson called TTL the floor under every other strategy: even with a broken write path, the staleness was bounded — thirty seconds, because that's what we chose at setEx time. Your TTL is literally the answer to "how long may this data lie to users if everything else fails?"

Break It #2: Kill Redis Mid-Flight

Final experiment — the one that separates a cache from a dependency. With the app running and warm, kill Redis dead:

$ redis-cli shutdown nosave
$ redis-cli ping
Could not connect to Redis at 127.0.0.1:6379: Connection refused

$ curl -s -i localhost:4000/product/1        # is the app dead too?
x-cache: MISS
{"id":1,"name":"Air Jordan 1 Retro","price":149}
time: 10.207172s

Two lessons in one output. First, the good news the patterns lesson promised: cache-aside means the cache is an optimization, not a dependency — Redis is a corpse and the app still returned a correct answer from the database. Nothing 500'd.

But look at the time. Ten seconds. Our "graceful" fallback wasn't graceful at all — the Redis client spent ten seconds internally retrying before our catch ever fired. The app survived, but every request was being held hostage by a dead dependency. This is a genuinely common production incident: the cache outage that doesn't take you down, but takes you slow — which at scale is the same thing.

The fix is a rule worth tattooing somewhere: never call a cache without a time budget. Race every cache call against a 50 ms timeout:

// never let a dead cache hold a request hostage: give every cache call a 50 ms budget
const withTimeout = (p, ms = 50) =>
  Promise.race([p, new Promise((_, rej) => setTimeout(() => rej(new Error('cache timeout')), ms))]);

const cached = await withTimeout(redis.get(key));       // was: await redis.get(key)
await withTimeout(redis.setEx(key, 30, JSON.stringify(product)));

Restart the server, kill Redis again, and re-run:

$ redis-cli shutdown nosave
$ curl -s -i localhost:4000/product/1
x-cache: MISS
time: 0.305804s

10.2 s → 0.31 s. A dead cache now costs two 50 ms timeouts on top of the normal database trip, and nothing more. Bring Redis back (redis-server --daemonize yes) and the very next read is a MISS that refills the cache — then HITs at 1.5 ms as if nothing happened. That is what "the cache can die safely" looks like when you've done it properly.

Clean Up

Two commands and your machine forgets the whole adventure:

redis-cli shutdown nosave      # stop Redis (or: brew services stop redis)
# Ctrl-C the node server, then delete the project folder

If you installed Redis just for this lab and want it gone: brew uninstall redis.

What Just Happened: Every Command Was a Concept

You just ran most of this chapter, for real:

  • The 212 ms → 1.5 ms hitWhy Caching Wins: the latency ladder, walked. The answer moved from a slow query to a RAM lookup, and keyspace_hits:21 / keyspace_misses:1 was the 95% hit rate whose effective-latency math you plugged real numbers into.
  • getProduct's three stepsCaching Patterns: cache-aside, verbatim — the app fills the cache, lazily, and (as break-it #2 proved) can lose the cache without losing correctness.
  • setEx … 30 and the countdownCache Eviction / Invalidation: TTL as shelf life, and as the bounded-staleness floor that executed your $189 lie right on schedule.
  • DB-first-then-DELCache Invalidation: delete-on-write done in the correct order, never updating the copy in place.
  • The 10-second hostage — the operational sibling of everything in Stampedes: it's not enough for a fallback to exist; a dead dependency must be cheap to fall past. (And had twenty clients hit that cold key concurrently, each would have run its own 200 ms query — the dogpile in miniature; single-flight is exactly the cure you simulated in that lesson.)

One deliberate gap: our "database" lived inside the server process. Real invalidation gets hard precisely when other writers exist — which is why the stale-set race and CDC from the invalidation lesson matter the moment this pattern meets production.

Key Takeaways & What's Next

  • Cache-aside is ~15 lines: get → miss → query → setEx with a TTL. Those 15 lines took a 212 ms endpoint to 1.5 ms on hits and cut a 20-request loop from 4.3 s to 0.1 s — measured, on your machine.
  • Operate on the hit rate (INFO statskeyspace_hits/misses), invalidate with DB-first + DEL, and treat the TTL as your worst-case honesty budget — it's what saved you when the write path forgot.
  • A cache must be cheap to lose: cache-aside kept the app correct with Redis dead, but only a time budget on every cache call (50 ms Promise.race) kept it fast — 10.2 s → 0.31 s. Fallbacks that can hang aren't fallbacks.

That closes the hands-on half of the caching chapter. Next — the §8 Checkpoint: ten design decisions across everything from hit-rate math to purging the planet. No new ideas, just the ones you now own, under mild pressure.