8 min read

Redis Caching Patterns: 5 That Actually Saved My Server

RedisCachingBackendPerformanceDatabase

A few years ago I watched a product launch almost kill our database. The landing page was fine, but the dashboard behind it — the one every new user hit within seconds of signing up — fired 200+ queries per page load. We'd load-tested it with 50 concurrent users and felt smug. Then the launch email went out, 3,000 people signed up in an hour, and Postgres started gasping. One query for the user's recent activity was running 400 times a second against a table with millions of rows.

The fix wasn't a bigger database. It was a cache in front of the database, and it took about two hours to implement.

That was my introduction to Redis, and I got it wrong several times before I got it right. I cached too much, cached for too long, and once managed to make a page slower because the cache serialization was more expensive than the query it replaced. Here are the five Redis caching patterns that actually survived contact with production, with real code, and the mistakes that taught me each one.

The One Pattern You'll Use 90% of the Time: Cache-Aside

Cache-aside (also called lazy loading) is the workhorse. The app checks Redis first, and only hits the database on a miss:

import { createClient } from 'redis'

const redis = createClient({ url: process.env.REDIS_URL })
await redis.connect()

async function getRecentActivity(userId) {
  const key = `activity:${userId}`

  // 1. Try the cache
  const cached = await redis.get(key)
  if (cached) return JSON.parse(cached)

  // 2. Miss — go to the database
  const rows = await db.query(
    'SELECT * FROM activity WHERE user_id = $1 ORDER BY created_at DESC LIMIT 20',
    [userId]
  )

  // 3. Populate the cache, then return
  await redis.set(key, JSON.stringify(rows), { EX: 300 })
  return rows
}

That's the whole pattern. Three steps, and it cut our dashboard's database load by roughly 90% the day we shipped it, because most users hit the same handful of endpoints over and over.

The mistakes I made with cache-aside, so you don't have to:

  • Don't cache inside a transaction. If you write the cache and the response together in one code path, a slow Redis call makes your API slower than the database ever was. Fire the cache write after the response has been sent, or don't await it.
  • Keep cached data small. Our first version cached full JSON documents including fields the UI never rendered. We cut payloads by 70% just by mapping rows to DTOs before caching. Redis is fast, but it's not a document database — and every megabyte you store is memory you're paying for.
  • Handle cache failures gracefully. If Redis is down, your app should fall through to the database, not throw. Wrap the cache calls in try/catch or use a circuit breaker. A cache is an optimization, not a dependency.

Why Your TTLs Are Probably Wrong

My first instinct was one TTL for everything: 5 minutes. It worked, until it didn't.

Two problems. First, data changes at different rates — a user's profile changes rarely, their activity feed changes constantly. A single TTL is wrong for both. Second, and sneakier: when every key expires at the same interval from the same moment, you get synchronized expiry. All the keys a busy endpoint touches die at once, and suddenly the database sees a wall of requests that all missed at the same second. That's how you get a "random" latency spike at :00 and :05 and :10 past every hour.

Here's what I do now:

  • Short TTL for hot, frequently-changing data — 30–60 seconds. The profile data a dashboard shows on every load. Even 30 seconds of caching collapses 99% of the query volume on a hot key.
  • Long TTL for slow-moving data — hours to a day. Lookup tables, config, category lists.
  • Add jitter — a random ±10% on top of the TTL so keys don't expire in lockstep. It costs you almost nothing and kills the synchronized-expiry problem:
const TTL = 300
const jittered = Math.floor(TTL * (0.9 + Math.random() * 0.2))
await redis.set(key, value, { EX: jittered })

One rule of thumb that's served me well: cache for the slowest acceptable staleness, not the fastest possible freshness. If showing data that's 60 seconds old is fine, don't set a 10-second TTL because it feels safer. Every second of TTL is a second of database load you're not paying for.

Defending Against the Thundering Herd

Here's the failure mode that took me the longest to understand. A key expires. Ten requests arrive in the same millisecond. All ten miss, all ten hit the database with the same expensive query, and all ten try to write the cache. The database — which you were protecting — just took the full load anyway, plus you did the work ten times.

The fix is a lock so only one request does the work and the rest wait for it:

async function getExpensive(key, ttl, fetch) {
  const cached = await redis.get(key)
  if (cached) return JSON.parse(cached)

  // Try to claim the lock — only one caller wins
  const lockKey = `lock:${key}`
  const acquired = await redis.set(lockKey, '1', {
    NX: true,   // only set if it doesn't exist
    EX: 5       // release automatically if we crash
  })

  if (!acquired) {
    // Someone else is fetching. Wait briefly, then retry.
    await sleep(50)
    const retry = await redis.get(key)
    if (retry) return JSON.parse(retry)
    return getExpensive(key, ttl, fetch) // still not there? try the lock again
  }

  try {
    const value = await fetch()
    await redis.set(key, JSON.stringify(value), { EX: ttl })
    return value
  } finally {
    await redis.del(lockKey)
  }
}

Two details matter here. The lock needs an EX so a crashed process doesn't hold it forever — that's the difference between a lock and a deadlock. And the retry loop needs a timeout so it can't spin forever if Redis is misbehaving.

In practice I use this pattern for the two or three most expensive queries in an app: the ones that join five tables or scan a big range. For everything else, the plain cache-aside plus jitter is enough. Don't gold-plate the cheap paths — the herd only forms on keys everyone hits at once.

Invalidation: The Part Everyone Skips

TTLs are fine for data that can be a little stale. But some data can't — a user's permissions, a payment status, an account flag. If you cache that with a 5-minute TTL, you've just introduced a 5-minute window where your app shows wrong state, and you'll debug it at 2 a.m. as "a weird bug that fixes itself."

For that data, invalidate explicitly on write:

// On the write path
await db.query('UPDATE users SET plan = $1 WHERE id = $2', [plan, userId])
await redis.del(`user:${userId}`)
await redis.del(`user:${userId}:permissions`)

Cache-aside then repopulates the key on the next read. This is the write-through approach, and it's the right default for anything correctness-sensitive. Two things I learned the hard way:

  • Delete, don't update. When a write happens, just remove the key and let the next read rebuild it. If you try to update the cached value in place, you'll miss a field, forget a related key, or serialize something wrong. Deleting is simple, and simple is what survives.
  • Track your key names. This is where cache invalidation gets you: you need to know every key a piece of data flows into. The moment you've got three code paths writing to user:123 and user:123:permissions and user:123:settings, you need the key scheme written down somewhere. I keep mine as comments in a central module — and honestly, the cleanest solution I've found is storing the key naming convention in my Snippet Ark library with a working invalidation example, so it's copy-pasteable into every project.

If you're on Redis 6+ you can also use keyspace notifications to invalidate automatically, but I'll be honest: the explicit del on the write path is easier to reason about and I've never needed the notification system in production. Start explicit.

Bonus Pattern: Rate Limiting with INCR

Not strictly caching, but it's the same Redis and it saves your API from the same class of problem. A sliding-window rate limit is one command plus an expiry:

async function rateLimit(userId, limit = 100, windowSecs = 60) {
  const key = `rl:${userId}:${Math.floor(Date.now() / (windowSecs * 1000))}`
  const count = await redis.incr(key)
  if (count === 1) await redis.expire(key, windowSecs)
  return count <= limit
}

INCR creates the key on first call, EXPIRE cleans it up after the window, and the whole thing is atomic. No locks, no race conditions, no cleanup jobs. This one pattern has stopped more abuse than any middleware I've ever written.

What I Stopped Caching (And You Should Too)

Knowing what not to cache is half the skill:

  • Per-user, rarely-repeated data. If a key is written once and read once, the cache is pure overhead — you paid to serialize, store, and deserialize something that was never going to be requested again.
  • Huge values. Over a few hundred KB, serialization cost starts eating your savings. At 1MB+, you're just using Redis as a slow object store.
  • Data that changes every request anyway. Real-time counters, live cursors, anything where the cache would be stale before it's useful.

The general test I use: would this key be read at least twice within its TTL? If yes, cache it. If no, let the database take it — that's literally what databases are for.

Measure Before and After

Every pattern above came with a measurement. Before Redis: the dashboard's average query time was 140ms and the database was at 85% CPU during peak. After cache-aside: 12ms average, database at 15%. That's the difference that makes caching feel like magic — it's not faster queries, it's fewer queries.

Watch one number above all: cache hit rate. INFO stats in redis-cli gives you keyspace_hits and keyspace_misses. If your hit rate is under 80%, you're caching the wrong things — either keys are too short-lived or too user-specific. If it's above 95%, you might be caching too aggressively and serving stale data. The number tells you which pattern to reach for next.

And if you're wondering about the launch that almost killed our database — it survived, the dashboard got fast, and the only casualty was my pride. Six months later the same traffic would have been fine, but not because the database got bigger. Because 90% of the requests never reached it.

Every pattern in this post lives in my Snippet Ark library as a ready-to-paste snippet — the cache-aside helper, the jittered TTL, the stampede lock, the rate limiter — so I don't have to retype them on every project. What's the one caching pattern you keep rewriting from memory? If it's saved anywhere, it should be somewhere you can grab it in one keystroke.