Get in Touch
Redis Cache Design Patterns: Practical Strategies for Faster Applications - Red Surge Technology Blog
RedisCachingBackendPerformanceWeb Development

Adding Redis to your application feels great for about a week. Your response times drop. Your database breathes easier. Everything feels snappy and responsive. Then the questions start creeping in.

How long should I cache this data? What happens when the underlying data changes? Should I update the cache immediately or wait for it to expire? What if Redis goes down — does my entire application stop working?

Caching is one of those things that's simple in concept and surprisingly nuanced in practice. The difference between a cache that actually improves your application and one that creates subtle, hard-to-debug consistency problems often comes down to which design pattern you choose and whether you understand its tradeoffs.

Redis gives you the primitives — GET, SET, DEL, EXPIRE, and a handful of more advanced commands. But the patterns for using those primitives effectively aren't always obvious. Here are the ones you'll actually use in production.

Cache-aside: the pattern you're probably already using

Cache-aside, sometimes called lazy loading, is the most common Redis caching pattern. The application checks the cache first. If the data is there, great — return it. If not, fetch it from the database, store it in Redis, and return it. Simple.

async function getUser(userId) {
  // Check cache first
  const cached = await redis.get(`user:${userId}`);
  if (cached) {
    return JSON.parse(cached);
  }

  // Cache miss — fetch from database
  const user = await db.query("SELECT * FROM users WHERE id = $1", [userId]);

  if (user) {
    // Store in cache with a TTL
    await redis.set(`user:${userId}`, JSON.stringify(user), "EX", 3600);
  }

  return user;
}

The appeal of cache-aside is that it's straightforward. Your application controls what gets cached and when. Nothing happens automatically, which means nothing happens unexpectedly. You don't populate the cache until something actually needs the data.

The downside is that the cache can fall out of sync with the database. When you update a user's profile, you need to remember to invalidate or update the cached version. Forget that step, and users see stale data until the TTL expires. These bugs are intermittent and maddening to reproduce because they only appear when the cache hasn't been invalidated.

Cache-aside works well for read-heavy workloads where data changes infrequently. User profiles, product descriptions, configuration settings — data that gets read hundreds of times for every write. The pattern falls apart when you need strong consistency guarantees.

Write-through: keeping the cache in sync

Write-through addresses the staleness problem by updating the cache at the same time as the database. Every write operation hits both the database and Redis before returning to the client.

async function updateUser(userId, data) {
  // Update database first
  await db.query("UPDATE users SET name = $1, email = $2 WHERE id = $3", [
    data.name,
    data.email,
    userId,
  ]);

  // Immediately update cache
  const user = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
  await redis.set(`user:${userId}`, JSON.stringify(user), "EX", 3600);

  return user;
}

Now the cache always reflects the latest data. Readers never see stale information because writers update the cache synchronously. This eliminates an entire category of consistency bugs.

The tradeoff is write latency. Every write operation now touches both the database and Redis. For write-heavy workloads, this adds up. You're also caching data that might never be read again — a user updates their profile three times in an hour, and you're paying the cache update cost each time regardless of whether anyone actually views that profile.

Write-through makes sense when you need strong consistency and your read volume significantly exceeds your write volume. Content management systems, e-commerce product catalogs, and configuration services often fit this profile.

Write-behind: speed now, persist later

Write-behind, also called write-back, flips the order. Writes go to Redis first and return immediately. Redis acknowledges the write, and the application moves on. Sometime later, a background process flushes the changes to the database.

async function recordPageView(pageId, userId) {
  // Write to Redis immediately
  const key = `pageviews:${pageId}`;
  await redis.zincrby(key, 1, userId);

  // That's it — the database flush happens asynchronously
  // A separate worker reads from Redis and writes to the database in batches
}

This is dramatically faster from the client's perspective. The write completes in microseconds because Redis is an in-memory data store. The slower database write happens later, outside the request-response cycle.

The risk is data loss. If Redis crashes between the write and the database flush, those writes are gone. Redis persistence can mitigate this — RDB snapshots and AOF logs reduce the window of potential loss — but the risk never drops to zero.

Write-behind works for high-volume data where occasional loss is acceptable. Analytics events, page view counters, rate limit tracking, activity logs. Data where precision is less important than performance. You wouldn't use write-behind for financial transactions or anything where losing a write has meaningful consequences.

Read-through: when the cache owns the data flow

Read-through is similar to cache-aside, but the cache layer itself handles fetching from the database. Your application only talks to Redis. If Redis has the data, it returns it. If not, Redis fetches from the database, stores the result, and returns it to the application.

This requires a Redis module like RedisGears or an application-level abstraction that sits between your code and the raw Redis commands. It's less common in practice because it adds complexity, but it's worth knowing about.

The advantage is that your application code becomes simpler. You don't write conditional logic for cache hits and misses. You always call the same function, and the caching layer handles the rest. This pattern also avoids the thundering herd problem — when a popular cache entry expires and dozens of simultaneous requests all try to repopulate it, hammering the database.

// Application code with a read-through abstraction
const user = await cache.get(`user:${userId}`, async () => {
  // This callback only runs on cache miss
  return db.query("SELECT * FROM users WHERE id = $1", [userId]);
});

The abstraction handles the cache check, the lock to prevent thundering herd, the database fetch, and the cache population. Your application code stays clean.

TTL strategies and expiration

One of the most practical Redis design decisions is how you handle expiration. Setting a TTL on every key prevents your cache from growing unbounded, but the strategy around TTL values deserves thought.

Fixed TTLs are the simplest approach. Cache everything for an hour. Or five minutes. Or whatever makes sense for your data freshness requirements. The problem is that fixed TTLs don't account for how frequently data actually changes.

Sliding TTLs reset the expiration timer every time data is accessed. A user session might have a 30-minute sliding TTL — as long as the user is active, their session stays in the cache. Once they stop making requests, the TTL expires naturally.

async function getUserSession(sessionId) {
  const session = await redis.get(`session:${sessionId}`);
  if (session) {
    // Reset TTL on every access
    await redis.expire(`session:${sessionId}`, 1800);
  }
  return session ? JSON.parse(session) : null;
}

Probabilistic early expiration adds randomness to prevent synchronized cache expirations. Instead of caching everything for exactly 3600 seconds, you add a random offset — between 3300 and 3900 seconds, for example. This spreads out the cache refresh load so your database doesn't get hammered when a batch of popular keys expires simultaneously.

const BASE_TTL = 3600;
const jitter = Math.floor(Math.random() * 600); // 0-600 seconds
const ttl = BASE_TTL + jitter;
await redis.set(key, value, "EX", ttl);

If you've been working on improving website page speed, caching strategies like this have an outsized impact. A well-configured cache can reduce database load by 90% or more, which translates directly to faster page loads and better user experience.

Cache invalidation: still the hard problem

Phil Karlton famously said there are two hard problems in computer science: cache invalidation and naming things. Redis doesn't solve the invalidation problem for you, but it gives you tools to manage it.

Key-based invalidation is the simplest approach. When you update a user, you delete the user:{id} key from Redis. The next read request experiences a cache miss, fetches fresh data from the database, and repopulates the cache.

async function updateUserProfile(userId, data) {
  await db.query("UPDATE users SET ... WHERE id = $1", [userId]);
  // Invalidate the cache
  await redis.del(`user:${userId}`);
  // Also invalidate any lists or aggregations that include this user
  await redis.del("users:active");
  await redis.del("users:premium");
}

The challenge is knowing which cached keys are affected by a given write. Updating a user's email might invalidate the user profile cache, the user list cache, the search index cache, and any dashboard aggregations that include user data. Keeping track of these dependencies gets complicated as your application grows.

Tag-based invalidation addresses this by grouping related cache entries. You tag cache entries with identifiers that describe what they contain, then invalidate by tag rather than by individual key.

// When caching, tag the entry
async function cacheUser(userId, user) {
  await redis.set(`user:${userId}`, JSON.stringify(user), "EX", 3600);
  await redis.sadd(`tag:user:${userId}`, `user:${userId}`);
}

// When invalidating, find all related keys by tag
async function invalidateUser(userId) {
  const keys = await redis.smembers(`tag:user:${userId}`);
  if (keys.length > 0) {
    await redis.del(...keys);
    await redis.del(`tag:user:${userId}`);
  }
}

Redis doesn't have native tag support, but you can build it with sets. Each tag is a set containing the keys associated with that tag. Invalidating by tag means fetching the set members and deleting them all. This adds some overhead but makes invalidation logic more maintainable.

The thundering herd and how to handle it

The thundering herd problem happens when a popular cache key expires and dozens of requests rush to repopulate it simultaneously. Each request sees the cache miss, queries the database, and writes back to Redis. The database gets hammered with identical queries, and most of the work is wasted.

The solution is a lock or a promise deduplication mechanism. The first request that encounters the cache miss acquires a lock, fetches the data, populates the cache, and releases the lock. Subsequent requests wait for the lock to release and then read from the cache.

async function getPopularData() {
  const cacheKey = "popular:items";

  // Try cache first
  const cached = await redis.get(cacheKey);
  if (cached) return JSON.parse(cached);

  // Try to acquire a lock
  const lockKey = `${cacheKey}:lock`;
  const acquired = await redis.set(lockKey, "1", "NX", "EX", 5);

  if (acquired) {
    // This request is responsible for fetching the data
    try {
      const data = await db.query("SELECT * FROM items WHERE popular = true");
      await redis.set(cacheKey, JSON.stringify(data), "EX", 300);
      return data;
    } finally {
      await redis.del(lockKey);
    }
  } else {
    // Another request is fetching — wait and retry
    await new Promise((resolve) => setTimeout(resolve, 100));
    return getPopularData(); // Retry
  }
}

The NX option on the SET command means "only set if the key does not exist." This creates an atomic lock — only one request will successfully set the key and become responsible for fetching the data. The lock has its own short TTL so a crashed request doesn't permanently block the cache.

This pattern adds latency for the requests that have to wait, but it prevents the database from being overwhelmed. For high-traffic applications, the tradeoff is well worth it.

What happens when Redis goes down

Caching adds a dependency, and dependencies fail. How your application behaves when Redis is unavailable is a design decision you should make deliberately, not discover during an outage.

The most common approach is graceful degradation. When Redis is unreachable, the application skips the cache and queries the database directly. Response times increase, but the application continues to function.

async function getUser(userId) {
  try {
    const cached = await redis.get(`user:${userId}`);
    if (cached) return JSON.parse(cached);
  } catch (err) {
    // Redis is down — log and continue
    logger.warn("Redis unavailable, falling back to database", err);
  }

  return db.query("SELECT * FROM users WHERE id = $1", [userId]);
}

This works as long as your database can handle the increased load. If your cache was absorbing 90% of read traffic, that traffic now hits the database directly. Make sure your database connection pooling and query performance can handle the worst case.

A circuit breaker takes this further. If Redis fails repeatedly, the circuit breaker opens and stops even trying Redis for a period. This prevents the connection timeouts from adding latency to every request during an extended outage.

const circuitBreaker = new CircuitBreaker(redis.get, {
  timeout: 3000,
  errorThresholdPercentage: 50,
  resetTimeout: 30000,
});

The circuit breaker pattern prevents a failing Redis from making your entire application slow by adding timeout delays to every request.

Compressing large values

Redis is fast, but network bandwidth isn't free. If you're caching large JSON blobs — search results, rendered HTML fragments, aggregated data — compression can significantly reduce memory usage and network transfer time.

const zlib = require("zlib");

async function cacheLargeData(key, data) {
  const compressed = zlib.deflateSync(JSON.stringify(data));
  await redis.set(key, compressed, "EX", 3600);
}

async function getLargeData(key) {
  const compressed = await redis.getBuffer(key);
  if (!compressed) return null;
  return JSON.parse(zlib.inflateSync(compressed).toString());
}

The compression overhead is usually negligible compared to the network savings, especially for text-based data that compresses well. JSON is highly compressible — compression ratios of 80-90% are common for API responses and HTML fragments.

Wrapping up

Redis caching patterns aren't one-size-fits-all. Cache-aside is the default for good reason — it's simple, predictable, and works for most situations. Write-through adds consistency at the cost of write latency. Write-behind maximizes write performance while accepting some risk of data loss. Read-through simplifies application code but requires more infrastructure.

The patterns you choose depend on your consistency requirements, your read-to-write ratio, and your tolerance for stale data. There's no universally correct answer, but there are answers that are clearly wrong for specific situations.

If you've been following our series on application performance, from why modern websites feel slower to our comparisons of backend frameworks like Django vs FastAPI, caching is one of the highest-leverage optimizations available. A few hours of thoughtful cache design can do more for performance than weeks of query optimization.

The key is being intentional. Know which pattern you're using. Know what happens when the cache is empty. Know what happens when the cache is wrong. And always have a plan for what your application does when Redis isn't there.


Need help designing a caching strategy for your application? Red Surge Technology helps teams implement Redis patterns that actually work in production. Get in touch to discuss your performance goals.