Mastering Distributed Caching Strategies with Redis

By Renvima 13 min read

Key Takeaways

  • Cache-Aside is the most common pattern and provides resilience if the cache fails
  • Cache invalidation is notoriously difficult — rely on TTLs and event-driven invalidation
  • Avoid the Thundering Herd problem by using cache locking or background refreshing
  • Redis is an in-memory data store, not just a cache — use its data structures (Sets, Hashes) for complex operations
  • Never cache user-specific sensitive data in a globally shared cache key without strict tenant isolation

As a web application scales, the database almost always becomes the primary bottleneck. Disk I/O, complex joins, and connection limits mean that a relational database can only handle so much throughput before response times degrade. The solution to database exhaustion is caching — storing frequently accessed, computationally expensive data in fast, in-memory storage.

In a distributed architecture (multiple API servers), local memory caches are insufficient because data becomes inconsistent across nodes. A distributed cache like Redis is required. In this guide, we explore the architectural patterns used to implement distributed caching effectively.

1. Why Distributed Caching?

If you have three Node.js API servers running behind a load balancer, and you use a local memory cache (like node-cache) on each:

  1. User A updates their profile on Server 1. Server 1 updates its database and its local cache.
  2. User A refreshes the page, and the load balancer routes them to Server 2.
  3. Server 2 serves the old profile data from its local cache. User A sees stale data.

A distributed cache (like a centralized Redis instance) ensures that all API servers read and write to the same shared memory pool, guaranteeing consistency across your infrastructure.

2. The Cache-Aside Pattern

Cache-Aside (or Lazy Loading) is the most common and resilient caching pattern. The application code interacts with both the cache and the database directly.

The Flow:

  1. Application checks the cache for data.
  2. If found (Cache Hit), return data immediately.
  3. If not found (Cache Miss), query the database.
  4. Store the result in the cache for future requests.
  5. Return data to the user.
// Implementing Cache-Aside with Node.js and Redis
async function getUserProfile(userId) {
    const cacheKey = `user:profile:${userId}`;

    // 1. Check Cache
    const cachedData = await redis.get(cacheKey);
    if (cachedData) {
        return JSON.parse(cachedData); // Cache Hit
    }

    // 2. Cache Miss: Query Database
    const user = await db.users.findById(userId);
    if (!user) return null;

    // 3. Populate Cache (with a TTL of 1 hour)
    await redis.set(cacheKey, JSON.stringify(user), 'EX', 3600);

    return user;
}

Advantage: If Redis goes down, the system degrades gracefully. The application simply experiences cache misses and falls back to the database.

3. Write-Through and Write-Behind

Write-Through Pattern

In this pattern, the application writes data to the cache and the database simultaneously. The cache is always up-to-date, meaning you never experience a cache miss on read. However, write operations incur higher latency because they must update two systems synchronously.

async function updateUserProfile(userId, data) {
    // Write to Database
    const updatedUser = await db.users.update(userId, data);

    // Write to Cache immediately
    const cacheKey = `user:profile:${userId}`;
    await redis.set(cacheKey, JSON.stringify(updatedUser), 'EX', 3600);

    return updatedUser;
}

Write-Behind (Write-Back) Pattern

The application writes data ONLY to the cache, returning success to the user immediately. An asynchronous process then writes the data from the cache to the database in the background. This provides blazing-fast write performance but carries the risk of data loss if the cache node crashes before the database sync completes.

4. The Hard Problem: Cache Invalidation

"There are only two hard things in Computer Science: cache invalidation and naming things." — Phil Karlton

When data changes in the database, the cached version becomes stale. Managing this is critical.

  • Time-To-Live (TTL): The simplest approach. Every cache key gets an expiration time. If you can tolerate stale data for 5 minutes, set a 300-second TTL. The cache self-cleans.
  • Event-Driven Invalidation: When a database write occurs, the application actively deletes (invalidates) the related cache keys.
// Event-driven invalidation
async function publishArticle(articleId, content) {
    await db.articles.update(articleId, content);

    // Invalidate specific cache keys
    await redis.del(`article:${articleId}`);
    // Invalidate list caches that contain this article
    await redis.del('articles:recent');
}

5. Solving the Thundering Herd Problem

The "Thundering Herd" (or Cache Stampede) occurs when a highly trafficked, computationally expensive cache key expires. Suddenly, 500 concurrent requests all experience a cache miss at the exact same millisecond. All 500 requests query the database simultaneously, potentially bringing the database down.

Solution: Cache Locking (Mutex)

When a cache miss occurs, only the first request is allowed to query the database. The other 499 requests wait for the first request to populate the cache.

async function getExpensiveData() {
    const key = 'dashboard:stats';
    let data = await redis.get(key);

    if (!data) {
        // Try to acquire a lock
        const lock = await redis.set(`lock:${key}`, '1', 'NX', 'EX', 10);

        if (lock) {
            // We got the lock! Query the DB.
            data = await runExpensiveDatabaseQuery();
            await redis.set(key, JSON.stringify(data), 'EX', 300);
            await redis.del(`lock:${key}`); // Release lock
        } else {
            // We didn't get the lock. Wait and retry reading the cache.
            await sleep(200);
            return getExpensiveData();
        }
    }
    return JSON.parse(data);
}

6. Beyond Simple Keys: Redis Data Structures

Redis is not just a key-value store; it is a data structure server. Leveraging its native types can simplify complex caching logic:

  • Hashes: Perfect for caching object properties. Instead of parsing a massive JSON string, you can update a single field: HSET user:100 name "New Name"
  • Sorted Sets: Ideal for caching leaderboards, recent activity feeds, or rate limiting. ZADD leaderboard 500 "player1"
  • Sets: Useful for fast, unique membership checks (e.g., checking if an IP address is in a blocked list).

Conclusion

Implementing a distributed cache transforms an application from sluggish to lightning-fast, protecting the database from traffic spikes and complex query loads. By combining the Cache-Aside pattern with intelligent TTLs, lock management to prevent stampedes, and event-driven invalidation, you can build highly resilient systems.

At Renvima, we engineer our bespoke full-stack SaaS applications with distributed caching architectures from day one, ensuring our clients' platforms scale effortlessly as their user base grows.

SC

Sophie Chen

Backend performance specialist with deep expertise in caching layers, CDNs, and distributed systems.

Build scalable web applications.

Renvima provides the architectural foundation for modern digital products.

Browse Templates