Make Your API Faster With Redis: Save Answers So the Database Gets a Break

Using Redis as a notebook of saved answers for your API: setting lifetimes, clearing cache safely, stopping a crowd from hitting the database at once, and sharing one Redis between sites.

Phát LâmPublished Updated 8 min readIntermediate
Make Your API Faster With Redis: Save Answers So the Database Gets a Break
On this page
  1. Picture a notebook on your desk
  2. Three places where it goes wrong
  3. How to do it
  4. For readers who write code
  5. Mistakes I keep seeing
  6. What to do next

A shop runs a promotion, traffic jumps several times over, and the product list page starts to crawl. You look closer and see that every visitor makes the database run the same heavy query, over and over, with the same result. The database is the first thing to fall over.

The usual cure is to remember the result for a while so the next visitor gets it instantly. That memory is called a cache, and Redis is where a lot of sites keep it. The idea is easy. Getting it right has a few traps, and this post walks through each one.

An app block joined by a short bright arrow to a small cache block, and a long amber loop to a database
The cache is close and fast, the database far and slow: ask the cache first.

Picture a notebook on your desk

Imagine you answer price questions for customers. The warehouse (the database) is at the far end of the street. Each time someone asks, you run over there. Instead, you keep a notebook on your desk. A customer asks, you check the notebook first. If the answer is there, you give it. If not, you run to the warehouse, write the result in the notebook, then answer.

That is the pattern called cache-aside: the app manages the notebook, and the warehouse knows nothing about it. Each line has an expiry (a TTL, time to live), and an expired line counts as missing. When the real data changes, you update the warehouse first, then cross out the matching line. I recommend crossing out rather than rewriting the line, because two updates at the same moment can leave the notebook disagreeing with the warehouse.

Three places where it goes wrong

Everything expires at once. If thousands of lines were written at 9:00 with a one-hour life, they all expire at 10:00 sharp and customers flood the warehouse. The fix is to add a little randomness to each line's lifetime (called jitter).

Crossing out the wrong lines, or missing some. One product list can sit in dozens of lines (each page, each filter, each language). Hunting them down one by one is easy to get wrong. A tidy trick is to put a version number in each line's name. When the data changes, raise the version, and every old line becomes an orphan that quietly expires.

The whole crowd runs to the warehouse. A popular line just expired, 500 customers ask at that moment, and 500 trips go to the warehouse for the same answer. This is a cache stampede. The cure is to let one person go while the others wait for that person's note (using a lock token in Redis), or to let people answer with the old version while one person fetches the new one.

A line of red cubes queuing at a narrow gate with only one teal cube passing through to the database
Stampede protection: only one request reaches the database while the rest wait.

How to do it

  1. Pick the slowest query that repeats the most. Do not cache everything.
  2. Set the lifetime by asking "how stale can this number be before the user minds?": tens of seconds for counters, a few minutes for product lists, hours for data that rarely changes. Add a little randomness.
  3. Start every key with a prefix that belongs to your app, for example shop.example.com:.
  4. When writing data: write to the database first, then delete or bump the cache.
  5. For heavily requested keys, add stampede protection.
  6. If Redis has trouble, the API must still answer, only slower.

Point 3 matters most when several sites share one Redis, as on a VPS that carries a few sites. Two sites that both use the key option:home read and overwrite each other's data. I tried it: two clients with the same prefix, A writes A, B writes B, and A reads back B. With different prefixes each side reads its own data. And never run FLUSHALL on a shared Redis, because it wipes every site's cache. To clean one site, scan by prefix with SCAN and then UNLINK. I deleted 6 keys of site A and site B's keys stayed intact.

For readers who write code

I reran these on a real Redis 8, Node 20 and ioredis. The tests used their own prefix and were cleaned up afterwards.

The Redis client is one shared copy (the globalThis pattern from One Shared Connection for the Whole App: Using Singleton Properly) with keyPrefix set so every key gets the app's prefix. The cache-aside function with jitter:

const jitter = (ttl: number, ratio = 0.1) => Math.round(ttl + Math.random() * ttl * ratio);

export async function cacheAside<T>(key: string, ttlSec: number, loader: () => Promise<T>): Promise<T> {
  const hit = await redis.get(key);
  if (hit !== null) return JSON.parse(hit) as T;
  const value = await loader();
  await redis.set(key, JSON.stringify(value), "EX", jitter(ttlSec));
  return value;
}

With a fake loader that takes 200 ms, the first call took about 202 ms, the next read from cache took about 0.3 ms, and the loader ran once. A 60-second life showed 64 seconds left after jitter. Those numbers come from my machine with a fake loader, so do not treat them as figures for any real query.

Versioned keys:

export async function versionedKey(group: string, ...parts: (string | number)[]): Promise<string> {
  const v = (await redis.get(`ver:${group}`)) ?? "0";   // default "0", not "1"!
  return `${group}:v${v}:${parts.join(":")}`;
}
export const bumpVersion = (group: string) => redis.incr(`ver:${group}`);

The "0" deserves attention. My first draft had ?? "1", but INCR on a missing key returns 1, so after bumping, the key name was still v1, the same as before, and the cache never refreshed. With "0" the name moves from products:v0:list:page1 to products:v1:list:page1, and the old key sits there until it expires. That is why every versioned key needs a lifetime.

Stampede protection with a distributed lock:

import { randomUUID } from "node:crypto";

const RELEASE = `if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end`;
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));

export async function cacheLocked<T>(key: string, ttlSec: number, loader: () => Promise<T>, lockMs = 10_000, waitMs = 3_000): Promise<T> {
  const hit = await redis.get(key);
  if (hit !== null) return JSON.parse(hit) as T;

  const lockKey = `lock:${key}`;
  const token = randomUUID();
  if ((await redis.set(lockKey, token, "PX", lockMs, "NX")) === "OK") {
    try {
      const value = await loader();
      await redis.set(key, JSON.stringify(value), "EX", jitter(ttlSec));
      return value;
    } finally {
      await redis.eval(RELEASE, 1, lockKey, token);   // release only our own lock
    }
  }
  const deadline = Date.now() + waitMs;               // lost the race: wait for the winner to fill the cache
  while (Date.now() < deadline) {
    await sleep(25);
    const v = await redis.get(key);
    if (v !== null) return JSON.parse(v) as T;
  }
  return loader();                                     // waited too long, do it ourselves
}

SET ... NX PX means "set only if nobody has, with an expiry", done in one step, so exactly one request wins the lock. The lock expires so a crashed process cannot keep it forever. When releasing, the Lua snippet checks the token so you never delete someone else's lock (releasing with a wrong token returns 0, the right token returns 1).

Result: 50 simultaneous requests on a cold key made cacheAside call the loader 50 times, and cacheLocked exactly once. I tried the same idea in PHP with phpredis: 8 processes at once, the version without a lock hit the database 8 times, the locked one once. phpredis has one trap: once you set a prefix with OPT_PREFIX, the library adds the prefix itself to keys passed to eval. If you add it by hand as well, the key gets the prefix twice and the lock is never released.

Another way is to serve the old copy while refreshing quietly in the background (stale-while-revalidate). With a "fresh" window of 1 second, the cold call took 200 ms, a fresh call 0.2 ms, and once stale it still returned the old copy in 0.5 ms, with the next call already seeing the new one. It suits list pages and stats where a few seconds of staleness is fine.

Mistakes I keep seeing

The first is letting a dead cache kill the API. A broken cache should make the API slower, not broken. Wrap cache reads and writes in try/catch and treat an error as a miss. I pointed the client at a port with no Redis and the wrapper still returned the right data from the loader.

The second: a lock lifetime shorter than the loader's run time. The lock expires while the first person is still working, a second person walks in, and you are back to the crowd at the warehouse. Make the lock lifetime longer than the worst-case run time.

The third is the order of deletion: clearing the cache before writing the database. A request that sneaks in between reloads the old data into the cache. The right order is database first.

What to do next

Take your single slowest query, wrap it in cacheAside with a short lifetime, and measure again. If that query is still so slow that every miss hurts, fix it first; Slow Query? How to Read EXPLAIN and Put Indexes in the Right Place starts there. And if your cache sits behind a public API, Designing an API Other People Will Use: Errors, Paging, Safe Retries and Limits covers the interface and rate limits.

RedisNode.jsPHPREST APIrediscache-asidecache-stampedettlstale-while-revalidateioredisphpredisprefix

References

  1. Microsoft Azure Architecture: Cache-Aside pattern
  2. Redis docs: SET (NX, PX, EX options)
  3. Redis docs: Distributed locks with Redis
  4. RFC 5861: HTTP Cache-Control Extensions for Stale Content
  5. ioredis (keyPrefix, eval)
  6. phpredis (OPT_PREFIX, eval)
  7. Redis Object Cache for WordPress: configuration (WP_REDIS_PREFIX, WP_REDIS_SELECTIVE_FLUSH)