Caching Strategies in Web Applications

Redis Fundamentals, Caching Patterns, and Effective Cache Invalidation

Introduction to Caching

Caching is one of the most powerful and widely-used techniques for improving application performance. At its core, caching is about storing frequently accessed data in a way that allows for faster retrieval than fetching it from the original source.

Think of caching like your brain's short-term memory. When you repeatedly need a piece of information, like a phone number you're dialing, you don't look it up every time—you remember it temporarily. This is much faster than searching for it again, and the same principle applies to computer systems.

flowchart LR
    Client[Client] -->|Request| AppServer[Application Server]
    
    subgraph System
    AppServer -->|Cache Hit| Cache[Cache Layer]
    Cache -->|Return Cached Data| AppServer
    AppServer -->|Cache Miss| Database[Database]
    Database -->|Return Data| AppServer
    AppServer -->|Store in Cache| Cache
    end
    
    AppServer -->|Response| Client
                

By implementing effective caching strategies, you can:

Today, we'll explore different caching strategies, learn Redis fundamentals, and implement various caching patterns to supercharge your web applications.

Caching Levels in Web Applications

Caching can be implemented at various levels in a web application architecture. Understanding these different levels helps you build a comprehensive caching strategy.

flowchart TD
    Client[Client Browser]
    CDN[Content Delivery Network]
    LB[Load Balancer / Reverse Proxy]
    AppServer[Application Server]
    DataCache[Data Cache]
    Database[Database]
    
    Client -->|1. HTTP Request| CDN
    CDN -->|2. Cache Miss| LB
    LB -->|3. Forward Request| AppServer
    
    subgraph API Gateway
    LB
    end
    
    subgraph Application Layer
    AppServer --> |4. Check Cache| DataCache
    DataCache -->|5. Cache Miss| AppServer
    AppServer -->|6. Query| Database
    Database -->|7. Response| AppServer
    AppServer -->|8. Store in Cache| DataCache
    DataCache -->|9. Cached Data| AppServer
    end
    
    AppServer -->|10. Response| LB
    LB -->|11. Response| CDN
    CDN -->|12. Cache for Future| CDN
    CDN -->|13. HTTP Response| Client
                

Browser Caching

The client's browser can store resources like HTML, CSS, JavaScript, images, and API responses.

  • Advantages: Zero network requests, extremely fast retrieval
  • Control Mechanisms: HTTP headers like Cache-Control, ETag, Last-Modified
  • Storage Options: HTTP cache, localStorage, sessionStorage, IndexedDB

CDN Caching

Content Delivery Networks store static assets and sometimes dynamic content at edge locations closer to users.

  • Advantages: Reduced latency, improved availability, offloads origin servers
  • Best For: Static assets, slowly changing content, geographically distributed users
  • Examples: Cloudflare, Akamai, Fastly, AWS CloudFront

Reverse Proxy Caching

Servers like Nginx or Varnish that sit in front of application servers and cache responses.

  • Advantages: Reduced load on app servers, faster responses, central point for cache control
  • Best For: HTML pages, API responses that are identical for all users
  • Examples: Nginx, Varnish, HAProxy

Application Caching

Caching implemented within your application code, often using in-memory stores or external caching systems.

  • Advantages: Fine-grained control, can cache complex objects, application-specific logic
  • Best For: Database query results, computed values, session data, API responses
  • Examples: Redis, Memcached, in-memory caches

Database Caching

Caching mechanisms within the database itself or as a layer in front of it.

  • Advantages: Optimized for database access patterns, can leverage database-specific features
  • Best For: Query results, frequently accessed records
  • Examples: Redis as a cache, PostgreSQL materialized views, MySQL query cache

For full-stack JavaScript applications, we'll focus primarily on application caching with Redis, which provides a versatile and powerful solution for most caching needs.

Redis Fundamentals

Redis (Remote Dictionary Server) is an open-source, in-memory data structure store that excels as a cache, database, and message broker. Its exceptional performance, versatility, and rich feature set make it the go-to choice for caching in modern web applications.

Key Redis Characteristics

Setting Up Redis

Let's start by setting up Redis for a Node.js application:

Installing Redis


# macOS with Homebrew
brew install redis

# Ubuntu/Debian
sudo apt update
sudo apt install redis-server

# Windows
# Download and install from https://github.com/tporadowski/redis/releases
            

Starting Redis


# macOS/Linux
redis-server

# With a custom config file
redis-server /path/to/redis.conf

# On Windows
# Start from the installed location or use the Windows Service
            

Adding Redis to Node.js Project


# Install the redis client library
npm install redis

# For additional features like JSON support
npm install redis @redis/json
            

Basic Redis Operations in Node.js


// Connect to Redis
const redis = require('redis');

async function main() {
  // Create client
  const client = redis.createClient({
    url: 'redis://localhost:6379'
  });
  
  // Set up error handler
  client.on('error', (err) => {
    console.error('Redis Error:', err);
  });
  
  // Connect to Redis
  await client.connect();
  console.log('Connected to Redis');
  
  // String operations
  // Set a key-value pair
  await client.set('greeting', 'Hello, Redis!');
  
  // Get a value
  const greeting = await client.get('greeting');
  console.log('Greeting:', greeting); // "Hello, Redis!"
  
  // Set with expiration (10 seconds)
  await client.set('temp-key', 'This will expire', {
    EX: 10
  });
  
  // Hash operations
  // Store a user profile
  await client.hSet('user:1000', {
    username: 'johndoe',
    email: 'john@example.com',
    visits: 10
  });
  
  // Get specific fields
  const email = await client.hGet('user:1000', 'email');
  console.log('Email:', email); // "john@example.com"
  
  // Get all fields
  const userProfile = await client.hGetAll('user:1000');
  console.log('User Profile:', userProfile);
  // { username: 'johndoe', email: 'john@example.com', visits: '10' }
  
  // Increment a numeric field
  await client.hIncrBy('user:1000', 'visits', 1);
  const visits = await client.hGet('user:1000', 'visits');
  console.log('Visits:', visits); // "11"
  
  // List operations
  // Add items to a list
  await client.lPush('recent-products', 'product:123');
  await client.lPush('recent-products', 'product:456');
  await client.lPush('recent-products', 'product:789');
  
  // Get items from a list
  const recentProducts = await client.lRange('recent-products', 0, -1);
  console.log('Recent Products:', recentProducts);
  // ["product:789", "product:456", "product:123"]
  
  // Set operations
  // Add members to a set
  await client.sAdd('active-users', 'user:1000');
  await client.sAdd('active-users', 'user:1001');
  await client.sAdd('active-users', 'user:1002');
  
  // Check if a member exists
  const isActive = await client.sIsMember('active-users', 'user:1000');
  console.log('Is User Active:', isActive); // true
  
  // Get all set members
  const activeUsers = await client.sMembers('active-users');
  console.log('Active Users:', activeUsers);
  // ["user:1000", "user:1001", "user:1002"]
  
  // Sorted set operations
  // Add members with scores
  await client.zAdd('leaderboard', [
    { score: 100, value: 'player:1' },
    { score: 75, value: 'player:2' },
    { score: 150, value: 'player:3' }
  ]);
  
  // Get top players
  const topPlayers = await client.zRangeWithScores('leaderboard', 0, 2, {
    REV: true // Reverse order (highest scores first)
  });
  console.log('Top Players:', topPlayers);
  // [{ value: 'player:3', score: 150 }, { value: 'player:1', score: 100 }, { value: 'player:2', score: 75 }]
  
  // Clean up
  await client.quit();
}

main().catch(console.error);
            

Redis Data Types and Use Cases

classDiagram
    class RedisDataTypes {
        String
        List
        Hash
        Set
        Sorted Set
        HyperLogLog
        Stream
        Geospatial
    }
    
    class StringUseCases {
        Simple key-value caching
        Counters
        Session storage
        Rate limiting
        Distributed locks
    }
    
    class ListUseCases {
        Activity feeds
        Recent items
        Message queues
        Timeline data
    }
    
    class HashUseCases {
        User profiles
        Object caching
        Session data
        Feature flags
    }
    
    class SetUseCases {
        Unique visitors
        Tagging systems
        Relationship tracking
        Access control lists
    }
    
    class SortedSetUseCases {
        Leaderboards
        Priority queues
        Time-series data
        Rate limiting
    }
    
    RedisDataTypes -- StringUseCases
    RedisDataTypes -- ListUseCases
    RedisDataTypes -- HashUseCases
    RedisDataTypes -- SetUseCases
    RedisDataTypes -- SortedSetUseCases
                

Each Redis data type is optimized for specific use cases. Understanding when to use each type is key to building efficient caching solutions.

Caching Patterns and Best Practices

Successfully implementing caching requires choosing the right patterns for your specific use cases. Let's explore the most common and effective caching patterns.

Cache-Aside (Lazy Loading)

In this pattern, the application first checks the cache for data. If the data isn't found (cache miss), it retrieves it from the database, then stores it in the cache for future requests.

sequenceDiagram
    participant A as Application
    participant C as Cache
    participant D as Database
    
    A->>C: Look for data
    alt Cache Hit
        C->>A: Return cached data
    else Cache Miss
        C->>A: Data not found
        A->>D: Query database
        D->>A: Return data
        A->>C: Store data in cache
    end
                

// Implementing Cache-Aside pattern
async function getUserById(userId) {
  const cacheKey = `user:${userId}`;
  
  // Try to get data from cache first
  const cachedUser = await redisClient.get(cacheKey);
  
  if (cachedUser) {
    console.log('Cache hit for user:', userId);
    return JSON.parse(cachedUser);
  }
  
  // Cache miss, get from database
  console.log('Cache miss for user:', userId);
  const user = await db.users.findOne({ _id: userId });
  
  if (user) {
    // Store in cache for future requests
    await redisClient.set(cacheKey, JSON.stringify(user), {
      EX: 3600 // Expire after 1 hour
    });
  }
  
  return user;
}
            

Advantages:

Disadvantages:

Cache-Through (Read-Through)

This pattern abstracts the caching layer so the application only interacts with the cache. The cache is responsible for loading data from the database when needed.

sequenceDiagram
    participant A as Application
    participant CL as Caching Layer
    participant C as Cache
    participant D as Database
    
    A->>CL: Get data
    CL->>C: Look for data
    alt Cache Hit
        C->>CL: Return cached data
        CL->>A: Return cached data
    else Cache Miss
        C->>CL: Data not found
        CL->>D: Query database
        D->>CL: Return data
        CL->>C: Store data in cache
        CL->>A: Return data
    end
                

// Cache service implementing Read-Through pattern
class CacheService {
  constructor(redisClient, dbClient) {
    this.redisClient = redisClient;
    this.dbClient = dbClient;
  }
  
  async get(key, fetchFunction, options = {}) {
    const { ttl = 3600, fetchParams = [] } = options;
    
    // Try to get from cache
    const cachedValue = await this.redisClient.get(key);
    
    if (cachedValue) {
      console.log(`Cache hit: ${key}`);
      return JSON.parse(cachedValue);
    }
    
    // Cache miss, fetch data using the provided function
    console.log(`Cache miss: ${key}`);
    const data = await fetchFunction(...fetchParams);
    
    if (data) {
      // Store in cache
      await this.redisClient.set(key, JSON.stringify(data), {
        EX: ttl
      });
    }
    
    return data;
  }
}

// Usage
const cacheService = new CacheService(redisClient, dbClient);

async function getUserById(userId) {
  return cacheService.get(
    `user:${userId}`,
    async (id) => await db.users.findOne({ _id: id }),
    {
      ttl: 3600,
      fetchParams: [userId]
    }
  );
}
            

Advantages:

Disadvantages:

Write-Through

In this pattern, data is written to both the cache and the database in the same operation. This ensures the cache is always consistent with the database.

sequenceDiagram
    participant A as Application
    participant C as Cache
    participant D as Database
    
    A->>A: Update operation
    A->>D: Write to database
    D->>A: Acknowledge write
    A->>C: Update cache
    C->>A: Acknowledge update
                

// Write-Through pattern implementation
async function updateUser(userId, userData) {
  // Update in database first
  const updatedUser = await db.users.findOneAndUpdate(
    { _id: userId },
    { $set: userData },
    { returnDocument: 'after' }
  );
  
  if (updatedUser) {
    // Update the cache with the latest data
    const cacheKey = `user:${userId}`;
    await redisClient.set(cacheKey, JSON.stringify(updatedUser), {
      EX: 3600 // Expire after 1 hour
    });
  }
  
  return updatedUser;
}
            

Advantages:

Disadvantages:

Write-Behind (Write-Back)

In this pattern, data is written to the cache immediately, but asynchronously written to the database later. This optimizes for write performance at the cost of data durability.

sequenceDiagram
    participant A as Application
    participant C as Cache
    participant Q as Write Queue
    participant D as Database
    
    A->>A: Update operation
    A->>C: Write to cache
    C->>A: Acknowledge write
    A->>Q: Queue database write
    Q->>A: Acknowledge queue
    Note over Q,D: Asynchronously
    Q->>D: Process queue
    D->>Q: Acknowledge write
                

// Simple Write-Behind implementation
class WriteBackCache {
  constructor(redisClient, dbClient) {
    this.redisClient = redisClient;
    this.dbClient = dbClient;
    this.writeQueue = [];
    this.processing = false;
    
    // Process the queue periodically
    setInterval(() => this.processWriteQueue(), 5000);
  }
  
  async update(key, data, collection) {
    // Update cache immediately
    await this.redisClient.set(key, JSON.stringify(data), {
      EX: 3600 // 1 hour
    });
    
    // Queue the database write
    this.writeQueue.push({
      collection,
      id: data._id,
      data,
      timestamp: Date.now()
    });
    
    return data;
  }
  
  async processWriteQueue() {
    if (this.processing || this.writeQueue.length === 0) {
      return;
    }
    
    this.processing = true;
    
    try {
      // Process in batches for efficiency
      const batch = this.writeQueue.splice(0, 50);
      
      // Group by collection for batch operations
      const operationsByCollection = {};
      batch.forEach(op => {
        if (!operationsByCollection[op.collection]) {
          operationsByCollection[op.collection] = [];
        }
        operationsByCollection[op.collection].push(op);
      });
      
      // Execute batch operations
      const promises = Object.entries(operationsByCollection).map(
        ([collection, operations]) => {
          return Promise.all(
            operations.map(op => 
              this.dbClient.collection(collection).updateOne(
                { _id: op.id },
                { $set: op.data }
              )
            )
          );
        }
      );
      
      await Promise.all(promises);
      console.log(`Processed ${batch.length} write operations`);
    } catch (error) {
      console.error('Error processing write queue:', error);
      // Push failed operations back to queue
      this.writeQueue.unshift(...batch);
    } finally {
      this.processing = false;
    }
  }
}

// Usage
const writeBackCache = new WriteBackCache(redisClient, dbClient);

async function updateUserProfile(userId, profileData) {
  const user = await getUserById(userId);
  
  if (!user) {
    throw new Error('User not found');
  }
  
  // Update user data
  const updatedUser = {
    ...user,
    ...profileData,
    updatedAt: new Date()
  };
  
  // Using write-behind cache
  return writeBackCache.update(
    `user:${userId}`,
    updatedUser,
    'users'
  );
}
            

Advantages:

Disadvantages:

Refresh-Ahead

This pattern proactively refreshes cached items before they expire, anticipating future requests and eliminating cache misses for frequently accessed data.

sequenceDiagram
    participant A as Application
    participant C as Cache
    participant CL as Cache Refresher
    participant D as Database
    
    A->>C: Look for data
    C->>A: Return cached data
    
    Note over C,CL: Cache item approaching expiration
    C->>CL: Notify of near-expiry
    CL->>D: Query database
    D->>CL: Return fresh data
    CL->>C: Update cache
                

// Refresh-Ahead pattern implementation
class RefreshAheadCache {
  constructor(redisClient, dbClient, options = {}) {
    this.redisClient = redisClient;
    this.dbClient = dbClient;
    this.options = {
      refreshThreshold: 0.75, // Refresh when 75% of TTL has elapsed
      defaultTTL: 3600, // 1 hour default TTL
      ...options
    };
  }
  
  async get(key, fetchFunction, options = {}) {
    const { ttl = this.options.defaultTTL, fetchParams = [] } = options;
    
    // Get value and metadata
    const cachedData = await this.redisClient.get(key);
    
    if (cachedData) {
      console.log(`Cache hit: ${key}`);
      const data = JSON.parse(cachedData);
      
      // Check TTL of the key
      const remainingTTL = await this.redisClient.ttl(key);
      const refreshThresholdTTL = ttl * this.options.refreshThreshold;
      
      // If approaching expiration, refresh asynchronously
      if (remainingTTL > 0 && remainingTTL < (ttl - refreshThresholdTTL)) {
        console.log(`Asynchronously refreshing: ${key}`);
        this.refreshData(key, fetchFunction, fetchParams, ttl);
      }
      
      return data;
    }
    
    // Cache miss, fetch from source
    console.log(`Cache miss: ${key}`);
    return this.refreshData(key, fetchFunction, fetchParams, ttl);
  }
  
  async refreshData(key, fetchFunction, fetchParams, ttl) {
    try {
      const freshData = await fetchFunction(...fetchParams);
      
      if (freshData) {
        await this.redisClient.set(key, JSON.stringify(freshData), {
          EX: ttl
        });
      }
      
      return freshData;
    } catch (error) {
      console.error(`Error refreshing data for key ${key}:`, error);
      throw error;
    }
  }
}

// Usage
const cacheService = new RefreshAheadCache(redisClient, dbClient);

async function getProductDetails(productId) {
  return cacheService.get(
    `product:${productId}`,
    async (id) => await db.products.findOne({ _id: id }),
    {
      ttl: 1800, // 30 minutes
      fetchParams: [productId]
    }
  );
}
            

Advantages:

Disadvantages:

Cache Invalidation Strategies

Cache invalidation is often considered one of the hardest problems in computer science. Deciding when and how to invalidate cached data is crucial for maintaining consistency between your cache and data source.

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

Time-Based Invalidation (TTL)

The simplest approach is to assign a Time To Live (TTL) to cached items, after which they automatically expire.


// Setting a key with TTL
await redisClient.set('session:1234', sessionData, {
  EX: 1800 // Expires in 1800 seconds (30 minutes)
});

// Setting a key with millisecond precision TTL
await redisClient.set('rate-limit:user123', '5', {
  PX: 60000 // Expires in 60000 milliseconds (1 minute)
});
            

Advantages:

Disadvantages:

Event-Based Invalidation

This approach invalidates cache entries when the underlying data changes, ensuring the cache remains consistent with the data source.


// Express route for updating a product
app.put('/api/products/:id', async (req, res) => {
  try {
    const productId = req.params.id;
    const updates = req.body;
    
    // Update in database
    const updatedProduct = await db.products.findOneAndUpdate(
      { _id: productId },
      { $set: updates },
      { returnDocument: 'after' }
    );
    
    if (!updatedProduct) {
      return res.status(404).json({ error: 'Product not found' });
    }
    
    // Invalidate cache entries related to this product
    const cacheKeys = [
      `product:${productId}`,                       // Individual product
      `products:category:${updatedProduct.category}`,  // Category listings
      'products:featured',                          // Featured products list
      'products:recent',                            // Recent products list
    ];
    
    // Delete keys that might contain this product
    if (cacheKeys.length > 0) {
      await redisClient.del(cacheKeys);
      console.log(`Invalidated cache for product ${productId}`);
    }
    
    res.json(updatedProduct);
  } catch (error) {
    console.error('Error updating product:', error);
    res.status(500).json({ error: 'Failed to update product' });
  }
});
            

Advantages:

Disadvantages:

Version-Based Invalidation

This approach associates a version or timestamp with cached data, allowing applications to detect and refresh stale data.


// Version-based cache invalidation
class VersionedCache {
  constructor(redisClient, versionKey = 'cache:versions') {
    this.redisClient = redisClient;
    this.versionKey = versionKey;
  }
  
  // Get data with version check
  async get(entityType, entityId, fetchFunction) {
    const cacheKey = `${entityType}:${entityId}`;
    const versionKey = `${this.versionKey}:${entityType}`;
    
    // Get cached data
    const cachedData = await this.redisClient.get(cacheKey);
    
    if (cachedData) {
      const data = JSON.parse(cachedData);
      
      // Check if version is current
      const currentVersion = await this.redisClient.hGet(versionKey, entityId) || '0';
      
      if (data.__version === currentVersion) {
        console.log(`Cache hit for ${cacheKey} (version ${currentVersion})`);
        return data;
      }
      
      console.log(`Cache version mismatch for ${cacheKey}: ${data.__version} vs ${currentVersion}`);
    }
    
    // Fetch fresh data
    const freshData = await fetchFunction(entityId);
    
    if (freshData) {
      // Get current version
      const version = await this.redisClient.hGet(versionKey, entityId) || '0';
      
      // Store with version
      const dataToCache = {
        ...freshData,
        __version: version
      };
      
      await this.redisClient.set(cacheKey, JSON.stringify(dataToCache), {
        EX: 3600 // 1 hour default TTL
      });
    }
    
    return freshData;
  }
  
  // Invalidate by incrementing version
  async invalidate(entityType, entityId) {
    const versionKey = `${this.versionKey}:${entityType}`;
    
    // Increment version to invalidate cache
    await this.redisClient.hIncrBy(versionKey, entityId, 1);
    console.log(`Invalidated ${entityType}:${entityId} by version increment`);
  }
}

// Usage
const versionedCache = new VersionedCache(redisClient);

// Fetch data with version check
async function getProduct(productId) {
  return versionedCache.get(
    'product',
    productId,
    async (id) => await db.products.findOne({ _id: id })
  );
}

// Update operation that invalidates cache
async function updateProduct(productId, updates) {
  // Update in database
  const result = await db.products.updateOne(
    { _id: productId },
    { $set: updates }
  );
  
  if (result.modifiedCount > 0) {
    // Invalidate by incrementing version
    await versionedCache.invalidate('product', productId);
  }
  
  return result;
}
            

Advantages:

Disadvantages:

Pattern-Based Invalidation

This approach uses key patterns to invalidate groups of related cache entries, which is especially useful for hierarchical or related data.


// Pattern-based cache invalidation
async function invalidateCachePattern(pattern) {
  // Use SCAN to find keys matching the pattern
  let cursor = '0';
  let keys = [];
  
  do {
    // SCAN for matching keys
    const reply = await redisClient.scan(cursor, {
      MATCH: pattern,
      COUNT: 100
    });
    
    cursor = reply.cursor;
    keys = keys.concat(reply.keys);
  } while (cursor !== '0');
  
  // Delete found keys
  if (keys.length > 0) {
    await redisClient.del(keys);
    console.log(`Invalidated ${keys.length} keys matching pattern: ${pattern}`);
  } else {
    console.log(`No keys found matching pattern: ${pattern}`);
  }
  
  return keys.length;
}

// Usage examples
// Invalidate all products in a category
async function invalidateCategoryProducts(categoryId) {
  return invalidateCachePattern(`product:category:${categoryId}:*`);
}

// Invalidate all user-related data
async function invalidateUserData(userId) {
  return invalidateCachePattern(`user:${userId}:*`);
}

// Invalidate all caches for API endpoints
async function invalidateAPICache() {
  return invalidateCachePattern('api:*');
}
            

Advantages:

Disadvantages:

Advanced Redis Features for Caching

Redis Pub/Sub for Cache Invalidation

Redis's publish/subscribe feature can be used to coordinate cache invalidation across multiple servers or services.


// Set up cache invalidation using Redis Pub/Sub
const CACHE_INVALIDATION_CHANNEL = 'cache:invalidation';

// Publisher (service that changes data)
async function publishCacheInvalidation(type, id) {
  const message = JSON.stringify({
    type,
    id,
    timestamp: Date.now()
  });
  
  await redisClient.publish(CACHE_INVALIDATION_CHANNEL, message);
  console.log(`Published cache invalidation: ${type}:${id}`);
}

// Subscriber (services that cache data)
async function setupCacheInvalidationSubscriber() {
  const subscriber = redisClient.duplicate();
  await subscriber.connect();
  
  await subscriber.subscribe(CACHE_INVALIDATION_CHANNEL, (message) => {
    try {
      const { type, id, timestamp } = JSON.parse(message);
      console.log(`Received cache invalidation: ${type}:${id}`);
      
      // Invalidate local cache
      const cacheKey = `${type}:${id}`;
      redisClient.del(cacheKey).catch(err => 
        console.error(`Error invalidating cache for ${cacheKey}:`, err)
      );
      
      // Additional invalidation logic as needed
      if (type === 'product') {
        redisClient.del('products:featured', 'products:recent')
          .catch(err => console.error('Error invalidating product lists:', err));
      }
    } catch (error) {
      console.error('Error processing invalidation message:', error);
    }
  });
  
  console.log('Subscribed to cache invalidation channel');
  
  // Handle subscriber errors
  subscriber.on('error', (err) => {
    console.error('Subscriber error:', err);
  });
  
  return subscriber;
}

// Usage in a microservice architecture
// Service 1: Updates data
app.put('/api/products/:id', async (req, res) => {
  try {
    const productId = req.params.id;
    const result = await updateProduct(productId, req.body);
    
    // Publish invalidation event to all services
    await publishCacheInvalidation('product', productId);
    
    res.json(result);
  } catch (error) {
    console.error('Error updating product:', error);
    res.status(500).json({ error: 'Failed to update product' });
  }
});

// Service 2: Receives invalidation events
(async function initService() {
  // Set up subscriber
  const subscriber = await setupCacheInvalidationSubscriber();
  
  // Rest of service initialization...
})();
            

Using Redis JSON for Complex Object Caching

The RedisJSON module allows storing and manipulating JSON documents natively in Redis, which is useful for caching complex objects.


// Set up Redis client with JSON support
const redis = require('redis');
const client = redis.createClient({
  url: 'redis://localhost:6379'
});

await client.connect();

// Store a complex object
const product = {
  id: '123',
  name: 'Wireless Headphones',
  price: 99.99,
  details: {
    brand: 'AudioTech',
    color: 'Black',
    features: ['Noise Cancellation', 'Bluetooth 5.0', '30-hour Battery']
  },
  inventory: {
    inStock: true,
    quantity: 45,
    warehouses: [
      { id: 'W1', quantity: 32 },
      { id: 'W2', quantity: 13 }
    ]
  }
};

// Store JSON document
await client.json.set('product:123', '$', product);

// Retrieve entire document
const retrievedProduct = await client.json.get('product:123');
console.log('Retrieved product:', retrievedProduct);

// Get specific paths
const price = await client.json.get('product:123', {
  path: '$.price'
});
console.log('Product price:', price);

// Update specific field
await client.json.set('product:123', '$.inventory.quantity', 42);

// Increment value
await client.json.numIncrBy('product:123', '$.inventory.quantity', 5);

// Array operations
await client.json.arrAppend('product:123', '$.details.features', 'Water Resistant');

// Multiple path updates in one call
await client.json.mSet([
  { key: 'product:123', path: '$.price', value: 89.99 },
  { key: 'product:123', path: '$.inventory.inStock', value: true }
]);
            

Redis Streams for Cache Invalidation Patterns

Redis Streams provide a more sophisticated append-only log that can be used for reliable cache invalidation across distributed systems.


// Using Redis Streams for cache invalidation
const INVALIDATION_STREAM = 'cache:invalidation:stream';
const CONSUMER_GROUP = 'cache:invalidation:consumers';
const CONSUMER_NAME = `consumer:${process.env.HOSTNAME || 'unknown'}`;

// Initialize stream and consumer group
async function initInvalidationStream() {
  try {
    // Create stream if it doesn't exist
    // First try to add a consumer group - this will fail if the stream doesn't exist
    try {
      await redisClient.xGroupCreate(
        INVALIDATION_STREAM,
        CONSUMER_GROUP,
        '0',
        { MKSTREAM: true }
      );
      console.log(`Created consumer group ${CONSUMER_GROUP} for stream ${INVALIDATION_STREAM}`);
    } catch (error) {
      // Group probably already exists, which is fine
      if (!error.message.includes('BUSYGROUP')) {
        console.error('Error creating consumer group:', error);
      }
    }
  } catch (error) {
    console.error('Error initializing invalidation stream:', error);
    throw error;
  }
}

// Add invalidation event to stream
async function addInvalidationEvent(entityType, entityId, operation = 'update') {
  const eventData = {
    type: entityType,
    id: entityId,
    operation,
    timestamp: Date.now().toString()
  };
  
  // Convert to Redis hash-like format
  const redisData = {};
  for (const [key, value] of Object.entries(eventData)) {
    redisData[key] = value.toString();
  }
  
  // Add to stream
  const eventId = await redisClient.xAdd(INVALIDATION_STREAM, '*', redisData);
  console.log(`Added invalidation event: ${eventId} - ${entityType}:${entityId}`);
  
  return eventId;
}

// Process invalidation events in the stream
async function processInvalidationEvents() {
  try {
    // Read new messages from the stream
    const streamMessages = await redisClient.xReadGroup(
      CONSUMER_GROUP,
      CONSUMER_NAME,
      [
        {
          key: INVALIDATION_STREAM,
          id: '>'  // New messages only
        }
      ],
      {
        COUNT: 10,  // Process 10 messages at a time
        BLOCK: 2000  // Block for 2 seconds if no messages
      }
    );
    
    if (!streamMessages || streamMessages.length === 0) {
      // No new messages
      return 0;
    }
    
    // Process messages
    const messagesForStream = streamMessages[0].messages;
    const processedIds = [];
    
    for (const msg of messagesForStream) {
      const { id, message } = msg;
      
      try {
        // Process the invalidation event
        console.log(`Processing invalidation event ${id}:`, message);
        
        const { type, id: entityId, operation } = message;
        
        // Perform cache invalidation
        const cacheKey = `${type}:${entityId}`;
        await redisClient.del(cacheKey);
        
        // Additional invalidation logic based on entity type
        if (type === 'product') {
          await redisClient.del('products:featured', 'products:recent');
          
          if (message.categoryId) {
            await redisClient.del(`products:category:${message.categoryId}`);
          }
        }
        
        // Mark this message as processed
        processedIds.push(id);
      } catch (error) {
        console.error(`Error processing invalidation event ${id}:`, error);
        // We don't add to processedIds so we'll retry this message later
      }
    }
    
    // Acknowledge processed messages
    if (processedIds.length > 0) {
      await redisClient.xAck(INVALIDATION_STREAM, CONSUMER_GROUP, processedIds);
      console.log(`Acknowledged ${processedIds.length} invalidation events`);
    }
    
    return messagesForStream.length;
  } catch (error) {
    if (error.message && error.message.includes('NOGROUP')) {
      // Consumer group doesn't exist, initialize it
      await initInvalidationStream();
      return 0;
    }
    
    console.error('Error processing invalidation events:', error);
    return 0;
  }
}

// Start processing invalidation events in the background
async function startInvalidationProcessor() {
  await initInvalidationStream();
  
  // Process events in a loop
  async function processLoop() {
    try {
      const processed = await processInvalidationEvents();
      
      // If we processed messages, continue immediately, otherwise wait a bit
      const delay = processed > 0 ? 0 : 1000;
      setTimeout(processLoop, delay);
    } catch (error) {
      console.error('Error in invalidation process loop:', error);
      setTimeout(processLoop, 5000); // Retry after 5 seconds on error
    }
  }
  
  // Start the loop
  processLoop();
  console.log('Invalidation processor started');
}

// Usage example
async function updateProductAndInvalidateCache(productId, updates) {
  // Update in database
  const result = await db.products.updateOne(
    { _id: productId },
    { $set: updates }
  );
  
  if (result.modifiedCount > 0) {
    // Add invalidation event to stream
    await addInvalidationEvent('product', productId, 'update');
  }
  
  return result;
}

// Initialize the system
(async function init() {
  await client.connect();
  await startInvalidationProcessor();
  
  // Rest of app initialization...
})();
            

Redis Cluster for Scalable Caching

For large-scale applications, Redis Cluster provides horizontal scaling, automatic sharding, and high availability.


// Connect to a Redis Cluster
const redis = require('redis');

// Cluster nodes
const clusterNodes = [
  { url: 'redis://10.0.0.1:6379' },
  { url: 'redis://10.0.0.2:6379' },
  { url: 'redis://10.0.0.3:6379' }
];

// Create client with cluster support
const client = redis.createClient({
  rootNodes: clusterNodes
});

// Error handling
client.on('error', (err) => {
  console.error('Redis Cluster Error:', err);
});

// Connect to cluster
await client.connect();
console.log('Connected to Redis Cluster');

// Usage is the same as with a single Redis instance
await client.set('user:1000', JSON.stringify({ name: 'John Doe' }));
const user = await client.get('user:1000');
console.log('User:', user);
            

Practical Caching Applications

API Response Caching

Implementing a middleware for caching API responses can significantly improve API performance and reduce database load.


// Express middleware for API response caching
function apiCache(redisClient, options = {}) {
  const {
    ttl = 60, // Default TTL in seconds
    keyPrefix = 'api:cache:',
    keyGenerator = null,
    shouldCache = null
  } = options;
  
  return async function cacheMiddleware(req, res, next) {
    // Skip caching for non-GET requests
    if (req.method !== 'GET') {
      return next();
    }
    
    // Generate cache key
    const key = keyGenerator
      ? keyGenerator(req)
      : `${keyPrefix}${req.originalUrl || req.url}`;
    
    // Check if we should cache this request
    if (shouldCache && !shouldCache(req)) {
      req.skipCache = true;
      return next();
    }
    
    try {
      // Try to get from cache
      const cachedResponse = await redisClient.get(key);
      
      if (cachedResponse) {
        const { statusCode, body, headers } = JSON.parse(cachedResponse);
        
        // Set headers from cached response
        if (headers) {
          for (const [name, value] of Object.entries(headers)) {
            res.setHeader(name, value);
          }
        }
        
        // Add cache header for transparency
        res.setHeader('X-Cache', 'HIT');
        
        // Send cached response
        return res.status(statusCode).send(body);
      }
      
      // Cache miss, capture the response
      const originalSend = res.send;
      
      res.send = function(body) {
        // Only cache successful responses
        if (res.statusCode >= 200 && res.statusCode < 300 && !req.skipCache) {
          const headers = {};
          
          // Get content-type and other important headers
          const headersToCache = ['content-type', 'content-language', 'content-encoding'];
          
          for (const header of headersToCache) {
            const value = res.getHeader(header);
            if (value) {
              headers[header] = value;
            }
          }
          
          // Store response in cache
          const cacheEntry = JSON.stringify({
            statusCode: res.statusCode,
            body,
            headers
          });
          
          redisClient.set(key, cacheEntry, { EX: ttl }).catch(err => {
            console.error('Error caching response:', err);
          });
        }
        
        // Add cache header for transparency
        res.setHeader('X-Cache', 'MISS');
        
        // Call original send
        return originalSend.call(this, body);
      };
      
      next();
    } catch (error) {
      console.error('Cache middleware error:', error);
      next();
    }
  };
}

// Usage in Express app
const express = require('express');
const redis = require('redis');

const app = express();
const redisClient = redis.createClient();

await redisClient.connect();

// Apply cache middleware to specific routes
app.get('/api/products', 
  apiCache(redisClient, { ttl: 300 }), // 5 minutes cache
  async (req, res) => {
    try {
      const products = await db.products.find().limit(20).toArray();
      res.json(products);
    } catch (error) {
      console.error('Error fetching products:', error);
      res.status(500).json({ error: 'Failed to fetch products' });
    }
  }
);

// Route with custom cache key
app.get('/api/products/category/:categoryId',
  apiCache(redisClient, {
    ttl: 600, // 10 minutes
    keyGenerator: (req) => `api:products:category:${req.params.categoryId}`
  }),
  async (req, res) => {
    try {
      const products = await db.products.find({
        categoryId: req.params.categoryId
      }).toArray();
      
      res.json(products);
    } catch (error) {
      console.error('Error fetching products by category:', error);
      res.status(500).json({ error: 'Failed to fetch products' });
    }
  }
);

// Route with conditional caching
app.get('/api/user/:userId/profile',
  apiCache(redisClient, {
    ttl: 1800, // 30 minutes
    // Don't cache if user is viewing their own profile (likely to change)
    shouldCache: (req) => req.params.userId !== req.user?.id
  }),
  async (req, res) => {
    // Profile retrieval logic...
  }
);
            

Session Storage

Using Redis for session storage provides better performance and easier scaling than traditional server-side sessions.


// Redis session storage for Express
const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const redis = require('redis');

const app = express();

// Create Redis client
const redisClient = redis.createClient({
  url: process.env.REDIS_URL || 'redis://localhost:6379'
});

redisClient.connect().catch(console.error);

// Configure session middleware
app.use(
  session({
    store: new RedisStore({ client: redisClient }),
    secret: process.env.SESSION_SECRET || 'your-secret-key',
    resave: false,
    saveUninitialized: false,
    cookie: {
      secure: process.env.NODE_ENV === 'production',
      httpOnly: true,
      maxAge: 1000 * 60 * 60 * 24 * 7 // 1 week
    }
  })
);

// Session usage example
app.post('/login', async (req, res) => {
  const { username, password } = req.body;
  
  // Authenticate user (simplified)
  const user = await authenticateUser(username, password);
  
  if (user) {
    // Store user info in session
    req.session.user = {
      id: user._id,
      username: user.username,
      role: user.role,
      lastLogin: new Date()
    };
    
    res.json({ success: true, message: 'Login successful' });
  } else {
    res.status(401).json({ success: false, message: 'Invalid credentials' });
  }
});

app.get('/profile', (req, res) => {
  // Check if user is logged in
  if (!req.session.user) {
    return res.status(401).json({ error: 'Not authenticated' });
  }
  
  // Use session data
  res.json({
    user: req.session.user,
    sessionID: req.sessionID
  });
});

app.post('/logout', (req, res) => {
  req.session.destroy((err) => {
    if (err) {
      return res.status(500).json({ error: 'Logout failed' });
    }
    
    res.clearCookie('connect.sid');
    res.json({ success: true, message: 'Logged out successfully' });
  });
});
            

Rate Limiting

Redis is perfect for implementing rate limiting to protect your API from abuse and ensure fair usage.


// Redis-based rate limiting middleware
function rateLimiter(redisClient, options = {}) {
  const {
    points = 10, // Number of requests allowed
    duration = 60, // Time window in seconds
    keyPrefix = 'ratelimit:',
    keyGenerator = null,
    errorMessage = 'Too many requests, please try again later',
    statusCode = 429 // Too Many Requests
  } = options;
  
  return async function rateLimitMiddleware(req, res, next) {
    try {
      // Generate key based on IP or user ID
      const identifier = req.user?.id || req.ip;
      const key = keyGenerator
        ? keyGenerator(req)
        : `${keyPrefix}${identifier}`;
      
      // Get current count and TTL
      const [current, ttl] = await Promise.all([
        redisClient.get(key),
        redisClient.ttl(key)
      ]);
      
      // Calculate points consumed and time left
      const count = current ? parseInt(current) : 0;
      const timeLeft = ttl > 0 ? ttl : duration;
      
      // Set headers for rate limit info
      res.setHeader('X-RateLimit-Limit', points);
      res.setHeader('X-RateLimit-Remaining', Math.max(0, points - count - 1));
      res.setHeader('X-RateLimit-Reset', Date.now() + timeLeft * 1000);
      
      // Check if rate limit exceeded
      if (count >= points) {
        return res.status(statusCode).json({
          error: errorMessage,
          retryAfter: timeLeft
        });
      }
      
      // Increment counter
      if (count === 0) {
        // First request in window, set with expiration
        await redisClient.set(key, 1, { EX: duration });
      } else {
        // Increment existing counter
        await redisClient.incr(key);
      }
      
      next();
    } catch (error) {
      console.error('Rate limit middleware error:', error);
      // Fail open - let request through if rate limiting fails
      next();
    }
  };
}

// Usage in Express app
app.use('/api/sensitive-endpoint',
  rateLimiter(redisClient, {
    points: 5,
    duration: 60, // 5 requests per minute
    keyPrefix: 'ratelimit:sensitive:'
  }),
  async (req, res) => {
    // Endpoint logic...
  }
);

// Different limits for logged-in vs anonymous users
app.use('/api/public',
  (req, res, next) => {
    // Apply different rate limits based on user status
    const limiterOptions = req.user
      ? { points: 100, duration: 60 } // 100 requests per minute for logged-in users
      : { points: 20, duration: 60 };  // 20 requests per minute for anonymous users
    
    rateLimiter(redisClient, limiterOptions)(req, res, next);
  },
  async (req, res) => {
    // Endpoint logic...
  }
);
            

Distributed Locks

Redis can be used to implement distributed locks, which are crucial for coordinating operations across multiple servers in a distributed system.


// Redis-based distributed lock
class RedisLock {
  constructor(redisClient, options = {}) {
    this.redisClient = redisClient;
    this.options = {
      lockPrefix: 'lock:',
      retryCount: 5,
      retryDelay: 200,
      lockTimeout: 10000, // 10 seconds
      ...options
    };
  }
  
  /**
   * Acquire a distributed lock
   * @param {string} resource - Resource identifier to lock
   * @param {object} options - Lock options
   * @returns {Promise} - Lock token if successful, null if failed
   */
  async acquire(resource, options = {}) {
    const {
      retryCount = this.options.retryCount,
      retryDelay = this.options.retryDelay,
      lockTimeout = this.options.lockTimeout
    } = options;
    
    const lockKey = `${this.options.lockPrefix}${resource}`;
    const token = this.generateToken();
    
    for (let attempt = 0; attempt <= retryCount; attempt++) {
      // Try to set the lock with NX option (only if it doesn't exist)
      const result = await this.redisClient.set(lockKey, token, {
        NX: true,
        PX: lockTimeout
      });
      
      if (result === 'OK') {
        return token; // Lock acquired successfully
      }
      
      // If we've reached max retries, give up
      if (attempt === retryCount) {
        return null; // Failed to acquire lock
      }
      
      // Wait before retrying
      await new Promise(resolve => setTimeout(resolve, retryDelay));
    }
    
    return null; // Should not reach here, but just in case
  }
  
  /**
   * Release a distributed lock
   * @param {string} resource - Resource identifier to unlock
   * @param {string} token - Lock token
   * @returns {Promise} - True if released, false if not owner
   */
  async release(resource, token) {
    const lockKey = `${this.options.lockPrefix}${resource}`;
    
    // Use Lua script to ensure atomic release
    const script = `
      if redis.call('get', KEYS[1]) == ARGV[1] then
        return redis.call('del', KEYS[1])
      else
        return 0
      end
    `;
    
    const result = await this.redisClient.eval(script, {
      keys: [lockKey],
      arguments: [token]
    });
    
    return result === 1;
  }
  
  /**
   * Extend the lock timeout
   * @param {string} resource - Resource identifier
   * @param {string} token - Lock token
   * @param {number} timeout - New timeout in milliseconds
   * @returns {Promise} - True if extended, false if not owner
   */
  async extend(resource, token, timeout = this.options.lockTimeout) {
    const lockKey = `${this.options.lockPrefix}${resource}`;
    
    // Use Lua script to ensure atomic extension
    const script = `
      if redis.call('get', KEYS[1]) == ARGV[1] then
        return redis.call('pexpire', KEYS[1], ARGV[2])
      else
        return 0
      end
    `;
    
    const result = await this.redisClient.eval(script, {
      keys: [lockKey],
      arguments: [token, timeout.toString()]
    });
    
    return result === 1;
  }
  
  /**
   * Generate a unique token for the lock
   * @returns {string} - Unique token
   */
  generateToken() {
    return `${Math.random().toString(36).substring(2)}:${Date.now()}`;
  }
}

// Usage example
async function processOrder(orderId) {
  const lock = new RedisLock(redisClient);
  const resource = `order:${orderId}`;
  
  // Try to acquire a lock
  const token = await lock.acquire(resource, { 
    lockTimeout: 30000 // 30 seconds
  });
  
  if (!token) {
    console.log(`Failed to acquire lock for order ${orderId}, already being processed`);
    return false;
  }
  
  try {
    console.log(`Processing order ${orderId}...`);
    
    // Simulate long-running process
    await processingLogic(orderId);
    
    // Operation completed successfully
    return true;
  } catch (error) {
    console.error(`Error processing order ${orderId}:`, error);
    throw error;
  } finally {
    // Always release the lock when done
    await lock.release(resource, token);
    console.log(`Released lock for order ${orderId}`);
  }
}

// Example with lock extension for long-running tasks
async function generateLargeReport(reportId) {
  const lock = new RedisLock(redisClient);
  const resource = `report:${reportId}`;
  
  // Try to acquire a lock
  const token = await lock.acquire(resource, { 
    lockTimeout: 60000 // 1 minute initial timeout
  });
  
  if (!token) {
    console.log(`Failed to acquire lock for report ${reportId}, already being generated`);
    return false;
  }
  
  // Set up automatic lock extension to prevent expiration during processing
  const extensionInterval = setInterval(async () => {
    const extended = await lock.extend(resource, token, 60000);
    if (extended) {
      console.log(`Extended lock for report ${reportId}`);
    } else {
      console.warn(`Failed to extend lock for report ${reportId}, it may have been lost`);
      clearInterval(extensionInterval);
    }
  }, 30000); // Extend every 30 seconds
  
  try {
    console.log(`Generating report ${reportId}...`);
    
    // Simulate long-running report generation
    await generateReportLogic(reportId);
    
    // Operation completed successfully
    return true;
  } catch (error) {
    console.error(`Error generating report ${reportId}:`, error);
    throw error;
  } finally {
    // Clear the extension interval
    clearInterval(extensionInterval);
    
    // Release the lock when done
    await lock.release(resource, token);
    console.log(`Released lock for report ${reportId}`);
  }
}
            

Database Query Caching

Caching database query results can significantly reduce database load and improve application performance.


// Database query caching system
class QueryCache {
  constructor(redisClient, options = {}) {
    this.redisClient = redisClient;
    this.options = {
      keyPrefix: 'query:',
      defaultTTL: 3600, // 1 hour
      ...options
    };
  }
  
  /**
   * Generate a cache key for a query
   * @param {string} collection - Collection or table name
   * @param {object} query - Query parameters
   * @param {object} options - Query options (projection, sort, limit, etc.)
   * @returns {string} - Cache key
   */
  generateKey(collection, query, options = {}) {
    // Create a deterministic string from the query and options
    const queryString = JSON.stringify(query, Object.keys(query).sort());
    const optionsString = JSON.stringify(options, Object.keys(options).sort());
    
    // Create a hash of the query+options for a shorter key
    const hash = require('crypto')
      .createHash('md5')
      .update(`${queryString}:${optionsString}`)
      .digest('hex');
    
    return `${this.options.keyPrefix}${collection}:${hash}`;
  }
  
  /**
   * Execute a query with caching
   * @param {string} collection - Collection or table name
   * @param {object} query - Query parameters
   * @param {object} options - Query options (projection, sort, limit, etc.)
   * @param {function} queryFn - Function to execute if cache miss
   * @param {object} cacheOptions - Caching options
   * @returns {Promise} - Query results
   */
  async query(collection, query, options = {}, queryFn, cacheOptions = {}) {
    const {
      ttl = this.options.defaultTTL,
      bypass = false,
      refresh = false
    } = cacheOptions;
    
    // Generate cache key
    const cacheKey = this.generateKey(collection, query, options);
    
    // Skip cache if bypass is true
    if (!bypass && !refresh) {
      // Try to get from cache
      const cachedResult = await this.redisClient.get(cacheKey);
      
      if (cachedResult) {
        console.log(`Cache hit for query: ${cacheKey}`);
        return JSON.parse(cachedResult);
      }
    }
    
    // Cache miss or bypass, execute query
    console.log(`Cache miss for query: ${cacheKey}`);
    const result = await queryFn();
    
    // Cache the result
    if (result && Array.isArray(result) ? result.length > 0 : result !== null) {
      await this.redisClient.set(cacheKey, JSON.stringify(result), {
        EX: ttl
      });
      
      // Store the key in a set for this collection for easy invalidation
      await this.redisClient.sAdd(`${this.options.keyPrefix}${collection}:keys`, cacheKey);
    }
    
    return result;
  }
  
  /**
   * Invalidate cache entries for a specific entity
   * @param {string} collection - Collection or table name
   * @param {string} entityId - Entity ID
   * @returns {Promise} - Number of keys invalidated
   */
  async invalidateEntity(collection, entityId) {
    // For precise invalidation, we'd need to track which queries include this entity
    // As a fallback, we'll scan for keys that might contain this entity
    
    // First, try to find keys with this ID in the cache key
    const pattern = `${this.options.keyPrefix}${collection}:*${entityId}*`;
    let cursor = '0';
    let invalidated = 0;
    
    do {
      const result = await this.redisClient.scan(cursor, {
        MATCH: pattern,
        COUNT: 100
      });
      
      cursor = result.cursor;
      const keys = result.keys;
      
      if (keys.length > 0) {
        await this.redisClient.del(keys);
        invalidated += keys.length;
      }
    } while (cursor !== '0');
    
    return invalidated;
  }
  
  /**
   * Invalidate all cache entries for a collection
   * @param {string} collection - Collection or table name
   * @returns {Promise} - Number of keys invalidated
   */
  async invalidateCollection(collection) {
    // Get all keys for this collection
    const keysSet = `${this.options.keyPrefix}${collection}:keys`;
    const keys = await this.redisClient.sMembers(keysSet);
    
    if (keys.length > 0) {
      // Delete all cache entries
      await this.redisClient.del(keys);
      
      // Clear the keys set
      await this.redisClient.del(keysSet);
    }
    
    return keys.length;
  }
}

// Usage in a service layer
class ProductService {
  constructor(db, redisClient) {
    this.db = db;
    this.queryCache = new QueryCache(redisClient);
    this.collection = 'products';
  }
  
  // Get products with caching
  async getProducts(filters = {}, options = {}) {
    return this.queryCache.query(
      this.collection,
      filters,
      options,
      async () => await this.db.collection(this.collection)
        .find(filters, options)
        .toArray()
    );
  }
  
  // Get a single product with caching
  async getProductById(productId) {
    return this.queryCache.query(
      this.collection,
      { _id: productId },
      {},
      async () => await this.db.collection(this.collection)
        .findOne({ _id: productId })
    );
  }
  
  // Update a product and invalidate cache
  async updateProduct(productId, updates) {
    const result = await this.db.collection(this.collection)
      .updateOne({ _id: productId }, { $set: updates });
    
    if (result.modifiedCount > 0) {
      // Invalidate cache for this product
      await this.queryCache.invalidateEntity(this.collection, productId);
      
      // If updates affect listing pages, consider invalidating collection
      if (updates.category || updates.price || updates.featured) {
        await this.queryCache.invalidateCollection(this.collection);
      }
    }
    
    return result;
  }
  
  // Delete a product and invalidate cache
  async deleteProduct(productId) {
    const result = await this.db.collection(this.collection)
      .deleteOne({ _id: productId });
    
    if (result.deletedCount > 0) {
      // Invalidate cache for this product
      await this.queryCache.invalidateEntity(this.collection, productId);
      
      // Invalidate collection cache for listing pages
      await this.queryCache.invalidateCollection(this.collection);
    }
    
    return result;
  }
}
            
        
        
        

Practice Activities

Activity 1: Implement Basic Redis Caching

Create a simple API endpoint that retrieves data from a database with Redis caching.

  1. Set up a Node.js project with Express and Redis
  2. Create a mock database function that returns data with an artificial delay
  3. Implement a cache-aside pattern for the API endpoint
  4. Add proper error handling and logging
  5. Test the endpoint with and without caching to compare performance

Bonus: Implement a way to manually invalidate the cache via an API endpoint

Activity 2: Advanced Caching Patterns

Extend your caching implementation with more advanced patterns:

  1. Implement the Read-Through caching pattern as a middleware
  2. Add Write-Through caching for data modification endpoints
  3. Implement cache versioning to handle invalidation
  4. Create a system for cache dependencies (e.g., product depends on category)
  5. Add telemetry to track cache hit/miss rates and performance

Activity 3: Redis Data Structures for Caching

Experiment with different Redis data structures for various caching scenarios:

  1. Use Redis Hashes to cache user profiles with selective field updates
  2. Implement a leaderboard using Redis Sorted Sets
  3. Create a recent activity feed using Redis Lists
  4. Use Redis Sets for category tagging and filtering
  5. Implement rate limiting using Redis Strings and expiration

Activity 4: Distributed Caching System

Build a more complex distributed caching system:

  1. Create multiple Node.js services that share a Redis cache
  2. Implement cache invalidation using Redis Pub/Sub
  3. Create a distributed locking mechanism for critical operations
  4. Add cache stampede protection using probabilistic early expiration
  5. Implement cache analytics to track usage patterns and optimize caching strategy

Common Caching Pitfalls and How to Avoid Them

Cache Invalidation Failures

Problem: Forgetting to invalidate cache entries after data updates, leading to stale data.

Solution:

  • Centralize invalidation logic in data access services
  • Use event-driven architecture to trigger invalidation
  • Apply proper TTLs as a fallback mechanism
  • Implement version-based caching for critical data

Cache Stampede (Thundering Herd)

Problem: When a popular cache entry expires, multiple concurrent requests hit the database simultaneously.

Solution:

  • Implement cache warming or refresh-ahead pattern
  • Use probabilistic early expiration (some requests refresh before expiration)
  • Implement request coalescing (first request rebuilds, others wait)
  • Add jitter to expiration times to distribute load

// Probabilistic early expiration
async function getWithProbabilisticEarlyExpiration(key, fetchFn, ttl = 3600) {
  const data = await redisClient.get(key);
  
  if (data) {
    // Get remaining TTL
    const remainingTtl = await redisClient.ttl(key);
    
    // If TTL is less than 20% of original or a 5% random chance,
    // asynchronously refresh
    if (remainingTtl < (ttl * 0.2) || Math.random() < 0.05) {
      refreshCache(key, fetchFn, ttl).catch(console.error);
    }
    
    return JSON.parse(data);
  }
  
  // Cache miss, fetch data
  return refreshCache(key, fetchFn, ttl);
}

async function refreshCache(key, fetchFn, ttl) {
  // Fetch fresh data
  const freshData = await fetchFn();
  
  // Store in cache
  await redisClient.set(key, JSON.stringify(freshData), {
    EX: ttl
  });
  
  return freshData;
}
                

Cache Inconsistency in Distributed Systems

Problem: Different nodes have different cached values for the same data.

Solution:

  • Use a centralized caching system like Redis
  • Implement consistent cache invalidation across nodes
  • Use event-based communication for cache updates
  • Apply versioning to detect inconsistencies

Memory Pressure and Cache Eviction

Problem: Redis running out of memory due to too much cached data.

Solution:

  • Set appropriate TTLs for all cache entries
  • Configure maxmemory and eviction policies in Redis
  • Monitor memory usage and eviction rates
  • Be selective about what you cache and for how long

// Redis configuration for memory management
// In redis.conf:

# Maximum memory limit
maxmemory 1gb

# Eviction policy
# allkeys-lru: Evict any key using approximated LRU
# volatile-lru: Evict keys with expiration using approximated LRU
# volatile-ttl: Evict keys with expiration using TTL
maxmemory-policy volatile-lru

# LRU and TTL precision
maxmemory-samples 5
                

Over-caching and Cache Pollution

Problem: Caching too much data or infrequently accessed data, wasting resources.

Solution:

  • Be strategic about what you cache (focus on hot data)
  • Use analytics to identify cache effectiveness
  • Implement adaptive TTLs based on access patterns
  • Periodically clean up unused cache entries

Conclusion and Best Practices

Effective caching is both an art and a science. It requires understanding your application's data access patterns, performance requirements, and consistency needs. Here are some final best practices to guide your caching implementations:

  • Cache by Need, Not Just by Default: Only cache data that is frequently accessed, expensive to generate, or relatively static.
  • TTLs Are Your Safety Net: Always set a reasonable TTL as a fallback mechanism for cache invalidation failures.
  • Monitor and Measure: Track cache hit rates, memory usage, and performance impact to optimize your caching strategy.
  • Plan for Failure: Cache should be a performance optimization, not a critical dependency. Your application should still function (albeit slower) if the cache fails.
  • Be Selective With Eviction Policies: Choose Redis eviction policies based on your application's needs (LRU for general purpose, TTL for time-sensitive data).
  • Consider Multi-Tier Caching: Combine different caching levels (browser, CDN, application, database) for maximum performance.
  • Regularly Review Caching Strategy: As your application evolves, periodically reassess your caching approach to ensure it still meets your needs.

Remember that caching is a powerful tool for improving application performance, but it introduces complexity and potential consistency issues. Use it wisely, monitor it carefully, and be prepared to adapt your strategy as your application grows.

Further Resources

Redis Documentation and Learning

Caching Patterns and Best Practices

Redis Tools and Extensions

Books and In-depth Resources