Caching: Improving Performance

Implementing caching strategies using Redis or other caching providers to improve application performance.


Cache Invalidation Strategies in NestJS

Understanding Cache Invalidation

Cache invalidation is the process of removing stale data from a cache to ensure that the application serves the most up-to-date information. A cache, by its very nature, stores data temporarily. If the underlying data changes, the cache needs to be updated (or invalidated) to reflect those changes. Failing to do so results in serving outdated information, which can lead to inconsistencies and a poor user experience. Effective cache invalidation is crucial for maintaining data consistency and freshness.

Cache Invalidation Strategies

1. Time-To-Live (TTL)

TTL is a simple strategy where each cached item is assigned a specific lifespan. After the TTL expires, the cache automatically invalidates that item.

Pros:

  • Easy to implement.
  • Guarantees that data will eventually be refreshed.

Cons:

  • May serve stale data for the duration of the TTL, even if the underlying data has changed.
  • Determining the optimal TTL value can be challenging – too short and you're defeating the purpose of caching; too long and you risk serving outdated data.

NestJS Example:

 import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common';
import { Cache } from 'cache-manager';

@Injectable()
export class MyService {
  constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}

  async getData(id: number): Promise<any> {
    const key = `data-${id}`;
    const cachedData = await this.cacheManager.get(key);

    if (cachedData) {
      return cachedData;
    }

    // Fetch data from the database or other source
    const data = await this.fetchDataFromSource(id);

    // Cache the data with a TTL of 60 seconds
    await this.cacheManager.set(key, data, { ttl: 60 }); // TTL in seconds

    return data;
  }

  private async fetchDataFromSource(id: number): Promise<any> {
    // Simulate fetching data from a database
    return new Promise(resolve => {
      setTimeout(() => {
        resolve({ id, value: `Data from source for ID ${id}` });
      }, 500);
    });
  }
} 

2. Manual Invalidation

Manual invalidation involves explicitly removing cached items when the underlying data changes. This requires your application to be aware of data modifications and trigger the invalidation process accordingly.

Pros:

  • Provides precise control over when data is invalidated.
  • Minimizes the time stale data is served.

Cons:

  • Requires more complex implementation.
  • Can be error-prone if not implemented correctly, leading to missed invalidations and stale data.
  • Needs careful coordination between data modification operations and cache invalidation logic.

NestJS Example:

 import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common';
import { Cache } from 'cache-manager';

@Injectable()
export class MyService {
  constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}

  async updateData(id: number, newData: any): Promise<void> {
    // Update the data in the database or other source
    await this.updateDataInSource(id, newData);

    // Invalidate the cache for this ID
    const key = `data-${id}`;
    await this.cacheManager.del(key);
  }

  async getData(id: number): Promise<any> {
    const key = `data-${id}`;
    const cachedData = await this.cacheManager.get(key);

    if (cachedData) {
      return cachedData;
    }

    // Fetch data from the database or other source
    const data = await this.fetchDataFromSource(id);

    // Cache the data
    await this.cacheManager.set(key, data);

    return data;
  }

  private async updateDataInSource(id: number, newData: any): Promise<void> {
    // Simulate updating data in a database
    return new Promise(resolve => {
      setTimeout(() => {
        console.log(`Data updated for ID ${id} with ${JSON.stringify(newData)}`);
        resolve();
      }, 500);
    });
  }

  private async fetchDataFromSource(id: number): Promise<any> {
    // Simulate fetching data from a database
    return new Promise(resolve => {
      setTimeout(() => {
        resolve({ id, value: `Data from source for ID ${id}` });
      }, 500);
    });
  }
} 

3. Event-Based Invalidation

Event-based invalidation uses a publish-subscribe pattern to notify subscribers (e.g., the cache) when data changes. When a data modification event occurs, a message is published, and the cache invalidates the relevant entries. This allows for decoupling data updates from cache management.

Pros:

  • Decoupled architecture – data modification logic doesn't need to directly interact with the cache.
  • Scalable – multiple caches can subscribe to the same event stream.
  • Real-time invalidation – cache is invalidated immediately upon data change.

Cons:

  • More complex to set up, requiring a message broker (e.g., Redis Pub/Sub, RabbitMQ, Kafka).
  • Reliability depends on the message broker's guarantees.
  • Requires careful planning of event structure and subscription logic.

NestJS Example (using Redis Pub/Sub):

This example assumes you have Redis installed and running.

 // app.module.ts
import { Module, CacheModule } from '@nestjs/common';
import { MyService } from './my.service';
import { RedisModule } from './redis.module'; //Custom Redis Module
import { RedisService } from './redis.service'; // Custom Redis Service

@Module({
  imports: [
    CacheModule.register({
      ttl: 5, // seconds
      max: 10, // maximum number of items in cache
    }),
    RedisModule.register({ // Register your Redis Module (see below)
      host: 'localhost',
      port: 6379,
    }),
  ],
  controllers: [],
  providers: [MyService, RedisService],
})
export class AppModule {}


// redis.module.ts (Custom Redis Module)
import { Module, DynamicModule, Provider } from '@nestjs/common';
import { RedisService } from './redis.service';
import { RedisModuleOptions } from './interfaces/redis-module-options.interface';
import { REDIS_MODULE_OPTIONS } from './redis.constants';

@Module({})
export class RedisModule {
  static register(options: RedisModuleOptions): DynamicModule {
    return {
      module: RedisModule,
      providers: [
        {
          provide: REDIS_MODULE_OPTIONS,
          useValue: options,
        },
        RedisService,
      ],
      exports: [RedisService],
    };
  }
}

// redis.service.ts (Custom Redis Service)
import { Injectable, Inject } from '@nestjs/common';
import { Redis, RedisOptions } from 'ioredis';
import { REDIS_MODULE_OPTIONS } from './redis.constants';
import { RedisModuleOptions } from './interfaces/redis-module-options.interface';

@Injectable()
export class RedisService {
  private readonly redisClient: Redis;
  private readonly redisSubscriber: Redis; // Separate client for pub/sub

  constructor(@Inject(REDIS_MODULE_OPTIONS) private readonly options: RedisModuleOptions) {
    this.redisClient = new Redis(options as RedisOptions);
    this.redisSubscriber = new Redis(options as RedisOptions); // Create a separate subscriber client

    this.redisSubscriber.subscribe('data.updated', (err, count) => {
      if (err) {
        console.error('Failed to subscribe: %s', err.message);
      } else {
        console.log(
          `Subscribed successfully! There are now ${count} channels subscribed to.`,
        );
      }
    });

    this.redisSubscriber.on('message', (channel, message) => {
      if (channel === 'data.updated') {
        const { id } = JSON.parse(message);
        console.log(`Received data.updated event for ID: ${id}, invalidating cache`);
        this.invalidateCache(id);
      }
    });
  }


  async invalidateCache(id: number) {
    const key = `data-${id}`;
    await this.redisClient.del(key);
    console.log(`Cache invalidated for key: ${key}`);
  }


  async publishDataUpdate(id: number) {
    const message = JSON.stringify({ id });
    await this.redisClient.publish('data.updated', message);
    console.log(`Published data.updated event for ID: ${id}`);
  }

  getClient(): Redis {
        return this.redisClient;
  }
}

// redis.constants.ts
export const REDIS_MODULE_OPTIONS = 'RedisModuleOptions';

//interfaces/redis-module-options.interface.ts
export interface RedisModuleOptions {
  host?: string;
  port?: number;
  db?: number;
  password?: string;
  username?: string;
  // ... other options from ioredis
}

// my.service.ts
import { CACHE_MANAGER, Inject, Injectable } from '@nestjs/common';
import { Cache } from 'cache-manager';
import { RedisService } from './redis.service';

@Injectable()
export class MyService {
  constructor(
    @Inject(CACHE_MANAGER) private cacheManager: Cache,
    private readonly redisService: RedisService,
  ) {}

  async updateData(id: number, newData: any): Promise<void> {
    // Update the data in the database or other source
    await this.updateDataInSource(id, newData);

    // Publish an event indicating that the data has been updated
    await this.redisService.publishDataUpdate(id); // Publish the event

  }


  async getData(id: number): Promise<any> {
    const key = `data-${id}`;
    const cachedData = await this.cacheManager.get(key);

    if (cachedData) {
      console.log("Cache hit!")
      return cachedData;
    }

    // Fetch data from the database or other source
    const data = await this.fetchDataFromSource(id);

    // Cache the data
    await this.cacheManager.set(key, data);
    console.log("Cache miss! Caching the data");
    return data;
  }

  private async updateDataInSource(id: number, newData: any): Promise<void> {
    // Simulate updating data in a database
    return new Promise(resolve => {
      setTimeout(() => {
        console.log(`Data updated for ID ${id} with ${JSON.stringify(newData)}`);
        resolve();
      }, 500);
    });
  }

  private async fetchDataFromSource(id: number): Promise<any> {
    // Simulate fetching data from a database
    return new Promise(resolve => {
      setTimeout(() => {
        resolve({ id, value: `Data from source for ID ${id}` });
      }, 500);
    });
  }
} 

Key points in the Event-Based example:

  • Redis Module: This example create a custom Redis Module to encapsulate the redis connection and subscriber logic.
  • Redis Service: This service manages the Redis connection, subscribes to a 'data.updated' channel, and invalidates the cache when a message is received. It also publishes update events. A separate redis connection is created specifically for pub/sub to avoid blocking the main redis connection.
  • Subscription: The Redis Service subscribes to `data.updated`.
  • Publishing events: The `updateData` method in `MyService` publishes a `data.updated` event when data is modified.
  • Invalidation: Upon receiving the event, the Redis service extracts the `id` from the message and calls the `invalidateCache` which deletes the corresponding cache key.

Choosing the Right Strategy

The best cache invalidation strategy depends on the specific requirements of your application:

  • TTL: Suitable for data that changes infrequently and can tolerate some staleness. Simple and easy to implement.
  • Manual Invalidation: Ideal when you need precise control over cache invalidation and can track data modifications.
  • Event-Based Invalidation: Best for real-time applications where data consistency is critical and data modifications are frequent.

In some cases, a combination of strategies might be appropriate. For example, you could use TTL as a fallback mechanism in case event-based invalidation fails.