/ Back to notes

November 21, 2024

5 min read

caching / database / performance

A 24-Hour Ranking Is Not a 24-Hour Cache: Caching Popular Posts With Redis

Caching a popular-posts ranking in Redis exposes two clocks that are easy to merge by accident: the activity window the ranking covers and how long a computed answer may be reused.

A 24-Hour Ranking Is Not a 24-Hour Cache: Caching Popular Posts With Redis

"Popular posts from the last 24 hours" sounds like it should be cached for 24 hours. That reading merges two different clocks, and the result can be a ranking that ignores the last day and still counts activity from almost two days ago.

I added Redis to an older project to cache a popular-posts list. The ranking is a good cache candidate: many readers request the same answer, and computing it means aggregating activity in the database on every request. This post is about the decision that turned out to matter more than the Redis commands: how old an answer is allowed to be.

What follows explains the design. It is not a benchmark. I have no before-and-after measurements of latency or database load, and the code below is illustrative rather than copied from the project.

Two clocks behind one list

The ranking window decides which activity counts: likes, views, or comments with a timestamp in the last 24 hours, measured from the moment the ranking is computed.

The cache lifetime decides how long that computed result may be served.

Put them together. If the ranking is computed at noon and kept for 24 hours, a reader at 11:59 the next morning gets a list covering activity from noon two days earlier to noon the previous day. The window says "last 24 hours". The answer actually covers a period that ended almost a day ago.

The oldest activity a reader can see is roughly window + cache lifetime + computation time. With a 24-hour window and a 24-hour TTL, that is close to 48 hours. For a daily digest, that may be fine. For a "trending now" box, it is not. The acceptable staleness is a product decision. The TTL comes after it.

Expiration removes the answer, it does not refresh it

Redis expiration deletes a key once its time to live runs out. It does not run your query again. Redis: EXPIRE Something in the application has to recompute the ranking, and that choice decides who pays for the recomputation.

With cache-aside, the first request after expiration pays:

// Illustrative cache-aside read for the ranking.
async function popularPosts(): Promise<Post[]> {
  const cached = await redis.get("ranking:popular:24h");
  if (cached) return JSON.parse(cached);

  const ranking = await computeRankingFromDatabase(); // the expensive aggregate
  await redis.set("ranking:popular:24h", JSON.stringify(ranking), { EX: 15 * 60 });
  return ranking;
}

The 15-minute TTL here is an example of a staleness budget, not a recommendation. It means a reader may see a ranking up to about 15 minutes older than the window implies.

A scheduled job is the other option: recompute every N minutes and overwrite the key, so no reader waits for the aggregate. That moves the cost off the request path. It also adds a job that can fail silently. If you go that way, give the key a TTL longer than the schedule, so a stuck job eventually makes the cache miss instead of serving a stale ranking forever.

The moment after expiration

Cache-aside has a predictable weak spot. When a popular key expires, every request arriving before the first recomputation finishes also misses, and each one runs the same expensive aggregate. On a hot page, that burst can hit the database exactly when the cache was supposed to protect it.

Common mitigations, none of which I can claim the project implemented:

  • Single flight: let one request recompute while the others wait, or receive the previous value if you kept a stale copy (next item). In Redis, a lock with SET lock:ranking <token> NX EX 30 is one way to elect that request. The lock's expiry has to be longer than the computation, and only the holder of <token> should release it. Redis: SET
  • Serve stale while refreshing: store the computation time with the value and refresh in the background when it passes a soft limit, keeping a longer hard TTL.
  • Precompute: the scheduled job above, which avoids the burst entirely.

The same question applies when Redis itself is unavailable. Falling back to the database keeps the page working only if the database can absorb every request recomputing the ranking at once. If it cannot, a degraded response, such as an empty "popular" box, may be the safer failure.

Choose the staleness budget first

The order I would follow now:

  1. Decide the maximum age of the ranking that the product accepts.
  2. Subtract the time the computation takes. What remains is the TTL, or the refresh interval.
  3. Decide who pays for recomputation: the next reader, or a background job.
  4. Decide what happens on a burst of misses and when Redis is down.
  5. Measure hit rate, database load, and response time under a comparable workload before claiming the cache helped.

Adding Redis takes a few lines. Most of the actual design is deciding which old answer is still useful, and for how long.