Back to Blog
NestJSRedisCacheService

NestJS Practical Guide — How to Build a Redis Cache Service

RaytonX
5 min read

1. Background and Design Considerations

In high-concurrency or frequently accessed scenarios, caching is crucial for improving performance and reducing database load. When designing a Redis cache service, consider the following:

  1. All caches in Redis: All data is stored in Redis, no in-memory first-level cache.
  2. Key organization strategy: Use regular keys for simple objects and hash tables for structured or grouped data.
  3. Field TTL: Hash table fields support automatic expiration by storing expiry information in the field value.
  4. Environment configuration: Flexible Redis settings for development, testing, and production environments.
  5. Unified interface: A common service interface for easy reuse across business modules.
  6. Extensibility: Support for Redis clusters, scheduled cleanup of expired fields, and other production-level features.

2. Implementing CacheService

1. Installing Dependencies

pnpm add @nestjs/cache-manager @keyv/redis

2. Configuring the CacheModule

// cache.module.ts
import { CacheModule } from "@nestjs/cache-manager";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { Global, Module } from "@nestjs/common";
import KeyvRedis from "@keyv/redis";

@Module({
  imports: [
    CacheModule.registerAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (configService: ConfigService) => {
        const redisUrl = configService.get<string>("REDIS_URL");
        if (!redisUrl) throw new Error("Missing REDIS_URL in env");
        return {
          stores: [new KeyvRedis(redisUrl)],
          ttl: 60 * 60 * 1000,
        };
      },
    }),
  ],
})
export class AppModule {}

3. Injecting the Cache Service Using CACHE_MANAGER

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

@Injectable()
export class CacheService {
  private readonly ttlSeconds = 60 * 60;
  private readonly emptyTtlSeconds = 60 * 60;

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

4. Regular Key Cache

For single objects or simple values, use regular Redis keys with TTL. The cache service should provide a unified interface:

  • Read from Redis first.
  • If missing, execute a callback to fetch the data and write it to Redis.
  • Support TTL settings and optional empty value handling.
export class CacheService {
  ...
  async getOrSet<T>(key: string, fn: () => Promise<T>, ttl = this.ttlSeconds): Promise<T> {
    const cached = await this.cacheManager.get<T>(key);
    if (cached) return cached;
    let result = await fn();
    if (result !== undefined && result !== null) {
      await this.cacheManager.set(key, result, ttlSeconds * 1000);
    } else {
      result = {} as Awaited<T>;
      await this.cacheManager.set(key, result, this.emptyTtlSeconds * 1000);
    }
    return result;
  }

  async set<T>(key: string, value: T, ttl = this.defaultTtl): Promise<void> {
    await this.cacheManager.set(key, value, ttl);
  }

  async get<T>(key: string): Promise<T | undefined> {
    return this.cacheManager.get<T>(key);
  }
  ...
}

5. Hash Table Cache

For structured or grouped data, use Redis hash tables:

  • Hash tables help organize fields logically by business group.
  • Each field can include an expiration time, checked on access.
  • Provide unified methods like hgetOrSet, hset, and hget for easy use.
export class CacheService {
  ...
  async hgetOrSet<T>(
    hash: string,
    field: string,
    fn: () => Promise<T>,
    ttlSeconds?: number
  ): Promise<T> {
    const redis = this.redisService.getClient();
    const value = await redis.hget(hash, field);
    if (value) {
      return JSON.parse(value) as T;
    }

    const data = await fn();
    const cacheData = ttlSeconds
      ? { data, expireAt: Date.now() + ttlSeconds * 1000 }
      : { data };
    await redis.hset(hash, field, JSON.stringify(cacheData));
    return data;
  }

  async hset<T>(hash: string, field: string, data: T) {
    const redis = this.redisService.getClient();
    const cacheData = ttlSeconds
      ? { data, expireAt: Date.now() + ttlSeconds * 1000 }
      : { data };
    await redis.hset(hash, field, JSON.stringify(cacheData));
  }

  async hget<T>(hash: string, field: string): Promise<T | undefined> {
    const redis = this.redisService.getClient();

    const value = await redis.hget(hash, field);
    if (!value) return null;
    const parsed = JSON.parse(value);
    if (parsed.expireAt && Date.now() > parsed.expireAt) {
      await this.redisService.hdel(hash, field);
      return null;
    }
    return parsed.data;
  }
  ...
}

6. Cleaning Expired Hash Fields

Since Redis hash fields do not natively support individual TTL, a cleanup routine is recommended in production:

  • Periodically iterate all fields in a hash.
  • Check expiration timestamps and remove expired fields.
  • Ensure cache accuracy and reduce memory usage in Redis.
async clearExpiredUsers() {
  const allFields = await this.redisService.hKeys('users');
  for (const userId of allFields) {
    const data = await this.redisService.getHash('users', userId);
    if (data?.expireAt && Date.now() > data.expireAt) {
      await this.redisService.hDel('users', userId);
    }
  }
}

3. Summary

This approach allows building a Redis-only caching service in NestJS with the following characteristics:

  • Unified Redis storage: All cache data in Redis, no first-level in-memory cache.
  • Key organization strategy: Use regular keys plus hash tables for grouping and clarity.
  • Field-level TTL: Supports automatic expiration for hash fields.
  • Environment-specific configuration: Flexible for dev/test/prod.
  • Unified service interface: Consistent API for business modules.
  • Production-ready extensibility: Supports clusters and scheduled cleanup tasks.

This caching strategy is simple, maintainable, and suitable for managing large amounts of cache in real-world NestJS projects.