---
title: "Make Your API Faster With Redis: Save Answers So the Database Gets a Break"
url: https://phatdev.net/en/blog/redis-cache-aside-api
lang: en
updated: 2026-10-21
alternate:
  vi: https://phatdev.net/vi/blog/redis-cache-aside-api
markdown: https://phatdev.net/en/blog/redis-cache-aside-api.md
---

# 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.

Categories: Backend | Published: 2026-10-19 | Level: Intermediate | 8 min read

Tags: redis, cache-aside, cache-stampede, ttl, stale-while-revalidate, ioredis, phpredis, prefix

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.

![The app asks a near, fast cache first and only takes the long road to the database on a miss](https://phatdev.net/uploads/blog/redis-cache-aside-api/cache-aside-1920.webp)

## 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](https://phatdev.net/uploads/blog/redis-cache-aside-api/stampede-1920.webp)

## 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](https://phatdev.net/en/blog/singleton-pattern-typescript-php)) with `keyPrefix` set so every key gets the app's prefix. The cache-aside function with jitter:

```ts
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:

```ts
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:

```ts
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](https://phatdev.net/en/blog/mysql-index-explain) starts there. And if your cache sits behind a public API, [Designing an API Other People Will Use: Errors, Paging, Safe Retries and Limits](https://phatdev.net/en/blog/rest-api-design-practical) covers the interface and rate limits.

## References

- [Microsoft Azure Architecture: Cache-Aside pattern](https://learn.microsoft.com/en-us/azure/architecture/patterns/cache-aside)
- [Redis docs: SET (NX, PX, EX options)](https://redis.io/docs/latest/commands/set/)
- [Redis docs: Distributed locks with Redis](https://redis.io/docs/latest/develop/use/patterns/distributed-locks/)
- [RFC 5861: HTTP Cache-Control Extensions for Stale Content](https://www.rfc-editor.org/rfc/rfc5861)
- [ioredis (keyPrefix, eval)](https://github.com/redis/ioredis)
- [phpredis (OPT_PREFIX, eval)](https://github.com/phpredis/phpredis)
- [Redis Object Cache for WordPress: configuration (WP_REDIS_PREFIX, WP_REDIS_SELECTIVE_FLUSH)](https://github.com/rhubarbgroup/redis-cache)

## Related posts

- [One Shared Connection for the Whole App: Using Singleton Properly](https://phatdev.net/en/blog/singleton-pattern-typescript-php.md)
- [Slow Query? How to Read EXPLAIN and Put Indexes in the Right Place](https://phatdev.net/en/blog/mysql-index-explain.md)
- [Designing an API Other People Will Use: Errors, Paging, Safe Retries and Limits](https://phatdev.net/en/blog/rest-api-design-practical.md)
