Skip to content

Caching with Redis in NestJS

Aman Kumar Singh7 min read
Part 18 of 40From the NestJS Production Guide series
Caching with Redis in NestJS — article by Aman Kumar Singh

In the last article, Transactions and Data Integrity, we made sure writes to Postgres either fully commit or fully roll back. That's the correctness half of a production backend. This article covers the other half: making reads fast once correctness stops being the bottleneck and latency starts being the complaint. It's part of the NestJS Production Guide series.

Caching is one of those topics every backend engineer nods along to and then implements badly under deadline pressure. The mechanics of redis.get and redis.set are trivial. The judgment calls around what to cache, for how long, and how to invalidate it correctly are where most production incidents actually come from.

I want to be upfront about the philosophy here, consistent with the rest of this series. Don't reach for Redis on day one. A well-indexed Postgres query on a table with a few hundred thousand rows is often fast enough that caching adds complexity without adding value. Add caching when you have a specific, measured problem: a query that's genuinely expensive to compute, an external API you're rate-limited on, or a read pattern that's wildly more frequent than the underlying data changes. Caching before you need it is a common way to introduce staleness bugs for no benefit.

Why caching earns its complexity

Every cache is a second source of truth that can disagree with the first one. That's the tax you pay. It's worth being explicit about what you get for it.

The classic case is a read that's expensive relative to how often the underlying data changes. Think of a dashboard that aggregates order totals across a tenant, a public-facing product page hit by thousands of anonymous users, or a call to a third-party API with a rate limit. In each case, recomputing the answer on every request is wasteful because the answer barely moves between requests.

Redis fits this job well because it's an in-memory store with sub-millisecond latency, supports TTLs natively, and every instance of your NestJS app can share the same cache instead of keeping a local, inconsistent one in process memory. That last point matters more than it sounds: an in-process cache (a plain Map in your service) works fine with one instance and quietly breaks the moment you scale horizontally, because each instance now has its own stale view of the world.

Setting up a Redis module in NestJS

I use ioredis over the redis package for production NestJS apps. It has better TypeScript types, built-in cluster and sentinel support, and handles reconnection more predictably. Wrap it in its own module so the connection is created once and shared everywhere through DI, rather than every service opening its own client.

// redis.module.ts
import { Module, Global } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import Redis from 'ioredis';

export const REDIS_CLIENT = 'REDIS_CLIENT';

@Global()
@Module({
  imports: [ConfigModule],
  providers: [
    {
      provide: REDIS_CLIENT,
      inject: [ConfigService],
      useFactory: (config: ConfigService) => {
        return new Redis({
          host: config.get<string>('REDIS_HOST', 'localhost'),
          port: config.get<number>('REDIS_PORT', 6379),
          password: config.get<string>('REDIS_PASSWORD'),
          maxRetriesPerRequest: 3,
          enableReadyCheck: true,
        });
      },
    },
  ],
  exports: [REDIS_CLIENT],
})
export class RedisModule {}

Marking it @Global() means you don't re-import it in every feature module. That's a reasonable exception to the usual rule of explicit imports: a Redis connection is genuinely app-wide infrastructure, not a feature concern.

The cache-aside pattern in practice

Cache-aside (also called lazy loading) is the pattern that covers the majority of real caching needs: check the cache first, fall back to the source of truth on a miss, and populate the cache on the way out. I wrap this in a small CacheService so the pattern lives in one place instead of being copy-pasted across every service that wants caching.

// cache.service.ts
import { Inject, Injectable, Logger } from '@nestjs/common';
import Redis from 'ioredis';
import { REDIS_CLIENT } from './redis.module';

@Injectable()
export class CacheService {
  private readonly logger = new Logger(CacheService.name);

  constructor(@Inject(REDIS_CLIENT) private readonly redis: Redis) {}

  async wrap<T>(
    key: string,
    ttlSeconds: number,
    loader: () => Promise<T>,
  ): Promise<T> {
    const cached = await this.redis.get(key);
    if (cached) {
      return JSON.parse(cached) as T;
    }

    const value = await loader();

    // Small jitter avoids many keys expiring at the exact same second
    // and hammering the source of truth all at once.
    const jitter = Math.floor(Math.random() * 10);
    await this.redis.set(key, JSON.stringify(value), 'EX', ttlSeconds + jitter);

    return value;
  }

  async invalidate(key: string): Promise<void> {
    await this.redis.del(key);
  }

  async invalidatePattern(pattern: string): Promise<void> {
    const stream = this.redis.scanStream({ match: pattern, count: 100 });
    const pipeline = this.redis.pipeline();
    for await (const keys of stream) {
      if (keys.length) {
        keys.forEach((k: string) => pipeline.del(k));
      }
    }
    await pipeline.exec();
  }
}

Using scanStream instead of KEYS for pattern-based invalidation matters in production. KEYS blocks the single-threaded Redis event loop while it scans the whole keyspace, which is fine on a laptop and a real problem on a busy shared instance. SCAN walks the keyspace in small cursor-based batches instead.

Wiring it into a service that reads tenant billing summaries looks like this:

// billing.service.ts
import { Injectable } from '@nestjs/common';
import { CacheService } from '../cache/cache.service';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Invoice } from './invoice.entity';

@Injectable()
export class BillingService {
  constructor(
    private readonly cache: CacheService,
    @InjectRepository(Invoice) private readonly invoices: Repository<Invoice>,
  ) {}

  async getMonthlySummary(tenantId: string, month: string) {
    const key = `billing:summary:${tenantId}:${month}`;

    return this.cache.wrap(key, 300, async () => {
      return this.invoices
        .createQueryBuilder('invoice')
        .select('SUM(invoice.amount)', 'total')
        .addSelect('COUNT(*)', 'count')
        .where('invoice.tenantId = :tenantId', { tenantId })
        .andWhere("to_char(invoice.createdAt, 'YYYY-MM') = :month", { month })
        .getRawOne();
    });
  }
}

Notice the key includes tenantId. In a multi-tenant app, forgetting to namespace cache keys per tenant is how one tenant's data ends up served to another, which is a far worse bug than a slow query ever was.

Invalidation: the part people underestimate

TTL-based expiry alone is fine for data where a few minutes of staleness is acceptable, like the billing summary above. It's not enough when a user takes an action and expects to see the result immediately. If someone updates their profile, they shouldn't have to wait out a TTL to see the change reflected.

For that, invalidate explicitly on write, right next to the mutation:

async updateInvoice(tenantId: string, invoiceId: string, dto: UpdateInvoiceDto) {
  const invoice = await this.invoices.save({ id: invoiceId, ...dto });
  await this.cache.invalidatePattern(`billing:summary:${tenantId}:*`);
  return invoice;
}

The tradeoff here is coupling: every write path that affects a cached read now has to know which cache keys it invalidates. On a small service that's manageable. On a larger one, it's worth centralizing invalidation logic (a small event, or a method on the same service that owns both the write and the cache) rather than scattering del calls across the codebase where they're easy to forget.

Production pitfalls worth planning for

A few things that don't show up until real traffic hits the cache.

Cache stampede. When a hot key expires, every concurrent request for it misses at the same time. All of them hit the database simultaneously, and it can look like a self-inflicted outage. The jitter in the wrap method above softens this by spreading expirations out. For genuinely hot keys, a distributed lock (only one request recomputes, the rest wait or serve slightly stale data) is worth the extra complexity.

Serialization cost. JSON.stringify and JSON.parse on large objects aren't free. If you're caching large payloads on every request, the serialization overhead can eat into the latency win you were chasing. Cache the smallest useful shape, not the whole entity graph.

Connection exhaustion. Each NestJS instance shares one ioredis connection by default, which is usually correct. Don't create a new Redis client per request; that's the fastest way to hit Redis's maxclients limit under load.

Caching mutable state without a plan. The riskiest caches are the ones nobody remembers exist. Six months later, someone changes the write path, forgets the read path is still serving from that key, and the cache quietly goes stale because nothing is invalidating it anymore. Keep the cache key and its invalidation logic close together in the code, ideally in the same file or service.

Key takeaways

  • Add Redis caching when you have a measured, specific latency or load problem, not as a default.
  • Cache-aside (check cache, fall back to source, populate on miss) covers most real-world needs.
  • Namespace cache keys by tenant in multi-tenant systems; a missing tenant ID in a key is a data leak waiting to happen.
  • Use `SCAN`-based invalidation instead of `KEYS` in production; `KEYS` blocks the Redis event loop.
  • Add TTL jitter and consider locking for hot keys to avoid cache stampedes.
  • Keep cache invalidation logic next to the write path it protects, not scattered across the codebase.

Frequently asked questions

What is the cache-aside pattern in Redis?

Cache-aside means the application checks Redis first, and only queries the source of truth (usually a database) on a cache miss, populating Redis with the result afterward. It's the most common caching pattern because the application stays in control of what gets cached and when.

How long should a Redis TTL be for a NestJS API?

It depends entirely on how tolerant the data is to staleness. A few minutes works for aggregated dashboards or reports. Data a user just edited should be invalidated explicitly on write rather than relying on TTL expiry alone.

Should I use ioredis or the redis package with NestJS?

Either works, but ioredis has more mature TypeScript support and built-in cluster and sentinel handling, which matters once you outgrow a single Redis instance. It's the more common choice for production NestJS services.

Why is using the KEYS command in Redis a problem in production?

`KEYS` scans the entire keyspace in one blocking operation on Redis's single-threaded event loop, which stalls every other client connected to that instance while it runs. `SCAN` achieves the same pattern-matching result incrementally, without blocking.

How do I avoid a cache stampede in NestJS?

Add small random jitter to your TTLs so keys don't all expire in the same instant, and for especially hot keys, use a short-lived distributed lock so only one request recomputes the value while others wait briefly or serve a slightly stale copy.

Do I need Redis if my Postgres queries are already fast?

No. If your indexed queries return in single-digit milliseconds under real load, adding a cache introduces staleness risk for a latency win you don't need. Measure first, cache second.

Related articles

WebSockets and Gateways — Aman Kumar Singh
Health Checks and Readiness Probes — Aman Kumar Singh
Performance Tuning Before Launch — Aman Kumar Singh
Aman Kumar Singh

About the author

I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.