Skip to content
ExponaBLOG
NESTJS · DAY 032 / 100

Caching with Redis in NestJS: what developers should know

Cache the expensive stuff and invalidate on purpose.

Expona Admin8 min read

Introduction

Redis is one of the most practical tools we reach for when a service starts feeling slow—but caching with Redis in NestJS requires intentionality, not just decorators. The difference between a cache that improves performance and one that creates silent data bugs often comes down to how you register it, when you invalidate it, and which unit system you're using for expiry.

In this guide, we'll walk through caching with Redis in NestJS: what developers should know, covering the setup patterns we've used across production e-commerce, SaaS, and ride-hailing platforms. We'll focus on explicit cache-aside logic, deliberate invalidation, and the upgrade gotchas that have caught teams off guard.

Setting up CacheModule with Redis for multi-instance setups

When you're running multiple instances of your NestJS service (and you should be in production), an in-memory cache is useless. Each instance has its own memory, so invalidating cache on one machine doesn't help the others. Redis solves this by giving you a shared store.

NestJS's CacheModule makes this straightforward. Register it once at the root level and inject it wherever you need it:

typescript
import { CacheModule } from '@nestjs/cache-manager';
import * as redisStore from 'cache-manager-redis-store';
import { Module } from '@nestjs/common';

@Module({
  imports: [
    CacheModule.register({
      isGlobal: true,
      store: redisStore,
      host: process.env.REDIS_HOST || 'localhost',
      port: process.env.REDIS_PORT || 6379,
      ttl: 300_000, // 5 minutes in milliseconds (v5+ default)
    }),
  ],
})
export class AppModule {}

Setting isGlobal: true means you won't need to re-import CacheModule in every feature module. The ttl parameter sets a default expiry, but you'll override this per key in most cases based on how stale that data can afford to be.

For production deployments, consider using a connection pool or sentinel mode if your Redis is clustered:

typescript
import { RedisClientOptions } from 'redis';

CacheModule.register({
  isGlobal: true,
  store: redisStore,
  // Use connection pool for high concurrency
  nodes: [
    { host: 'redis-1.internal', port: 6379 },
    { host: 'redis-2.internal', port: 6379 },
  ],
  // or for sentinel
  sentinels: [
    { host: 'sentinel-1', port: 26379 },
    { host: 'sentinel-2', port: 26379 },
  ],
  name: 'mymaster', // Sentinel master name
})

Once registered globally, any service can inject the CACHE_MANAGER token and start using it.

Cache-aside in services: explicit get and set over magic

The cache-aside pattern (also called "lazy loading") is where you check the cache first, and if there's a miss, you fetch from the source and repopulate the cache. It's explicit, predictable, and gives you full control.

Some teams reach for decorators like @Cacheable() because they look clean, but decorators hide the logic and make invalidation patterns hard to reason about. We prefer writing it out in the service:

typescript
import { Injectable, Inject } from '@nestjs/common';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { PrismaService } from './prisma.service';

interface Product {
  id: string;
  name: string;
  price: number;
  updatedAt: Date;
}

@Injectable()
export class ProductService {
  constructor(
    @Inject(CACHE_MANAGER) private cache: Cache,
    private prisma: PrismaService,
  ) {}

  async getProduct(id: string): Promise {
    const key = `product:${id}`;
    
    // Try cache first
    const cached = await this.cache.get(key);
    if (cached) {
      return cached;
    }

    // Cache miss: fetch from database
    const product = await this.prisma.product.findUnique({
      where: { id },
    });

    if (!product) {
      return null;
    }

    // Repopulate cache (5 minutes in milliseconds)
    await this.cache.set(key, product, 300_000);

    return product;
  }
}

Why is this better? You can see exactly when we check, when we fetch, and when we cache. If debugging is needed, there's no magic to trace through. The TTL is explicit and in the same place you're doing the caching.

Invalidate on write: deleting keys when data changes

A cache is only useful if it's fresh enough. The moment a product is updated in the database, the cached version is stale and dangerous. You must delete it.

The pattern is simple: whenever you write to the source of truth, delete the cache key:

typescript
@Injectable()
export class ProductService {
  constructor(
    @Inject(CACHE_MANAGER) private cache: Cache,
    private prisma: PrismaService,
  ) {}

  async updateProduct(
    id: string,
    data: Partial,
  ): Promise {
    // Update the database
    const updated = await this.prisma.product.update({
      where: { id },
      data,
    });

    // Invalidate the cache immediately
    const key = `product:${id}`;
    await this.cache.del(key);

    return updated;
  }

  async deleteProduct(id: string): Promise {
    await this.prisma.product.delete({
      where: { id },
    });

    // Clean up the cache
    const key = `product:${id}`;
    await this.cache.del(key);
  }

  async listProducts(categoryId: string): Promise {
    const key = `products:category:${categoryId}`;
    const cached = await this.cache.get(key);
    if (cached) {
      return cached;
    }

    const products = await this.prisma.product.findMany({
      where: { categoryId },
    });

    await this.cache.set(key, products, 600_000); // 10 minutes
    return products;
  }

  async updateProductCategory(id: string, categoryId: string) {
    const product = await this.prisma.product.findUnique({
      where: { id },
    });

    // Invalidate the individual product cache
    await this.cache.del(`product:${id}`);

    // Invalidate any category lists that might include this product
    await this.cache.del(`products:category:${product.categoryId}`);
    await this.cache.del(`products:category:${categoryId}`);

    return await this.prisma.product.update({
      where: { id },
      data: { categoryId },
    });
  }
}

Notice how we invalidate not just the individual product key, but also any list keys that might be affected. This gets complex fast, especially with related data. Some teams use cache tags or namespaces to group related keys and clear them together:

typescript
async invalidateProductRelated(productId: string, oldCategoryId?: string) {
  // Delete all keys matching a pattern
  // Note: your Redis client needs to support SCAN for this
  const keys = await this.redis.keys(`product:${productId}:*`);
  if (keys.length) {
    await this.cache.del(...keys);
  }

  // Also clear category caches
  if (oldCategoryId) {
    await this.cache.del(`products:category:${oldCategoryId}`);
  }
}

Mind the TTL units: milliseconds in cache-manager v5+

This is the gotcha that catches everyone after an upgrade. In cache-manager v5+, TTL is in milliseconds, not seconds.

If you upgrade from v4 to v5 without changing your code, a key you intended to cache for 5 minutes will expire in 5 milliseconds. You won't see an obvious error—the cache will just never hit, and your database will get hammered.

Pre-v5 code:

typescript
// v4: seconds
await this.cache.set(key, value, 300); // 5 minutes

Post-v5 code:

typescript
// v5+: milliseconds
await this.cache.set(key, value, 300_000); // 5 minutes (300,000 ms)

The fix: use constants to avoid magic numbers and make the intention clear:

typescript
export const CACHE_TTL = {
  PRODUCT: 5 * 60 * 1000,        // 5 minutes
  INVENTORY: 1 * 60 * 1000,      // 1 minute
  USER_SESSION: 30 * 60 * 1000,  // 30 minutes
  CATEGORY_LIST: 10 * 60 * 1000, // 10 minutes
} as const;

// Usage
await this.cache.set(key, value, CACHE_TTL.PRODUCT);

You can also set a default TTL in your CacheModule config and only override when needed. But being explicit is safer.

Common mistakes

  • Forgetting to invalidate related keys: You cache a product, then cache a category listing that includes it. When the product changes, you delete the product key but leave the stale listing. The listing serves out-of-date data. Always trace your read paths and invalidate proactively.
  • Caching null or error states: A query fails, you cache null, and now every request gets the cached null instead of retrying the database. Either don't cache misses, or use a much shorter TTL for them (e.g., 30 seconds instead of 5 minutes).
  • Not handling connection failures: If Redis goes down, your cache operations will throw. Wrap cache calls in try-catch or use a fallback. Missing cache shouldn't break the feature.
  • Caching mutable objects without serialization: If you cache a JavaScript object and later mutate it in memory, the cached copy is also mutated. Store immutable snapshots or clone on retrieval.
  • TTL unit confusion after upgrades: As mentioned, v5+ uses milliseconds. Always check your version and test after upgrading.

Key takeaways

  • Register CacheModule with isGlobal: true and a Redis store once at the application root, not per-module.
  • Use the cache-aside pattern with explicit get and set logic in your services, not decorators—you need to see and control when caching happens.
  • Invalidate cache keys aggressively whenever you write to the source of truth. Trace all read paths that depend on that data and invalidate them too.
  • In cache-manager v5+, TTL is in milliseconds. Use named constants to avoid the post-upgrade gotcha.
  • Don't cache errors, nulls, or mutable state without careful consideration. Consider the cost of a stale answer versus a miss.
  • Always wrap cache operations in error handling—Redis going down should degrade gracefully, not crash your service.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles