Saikat
← Back to blog

Redis Caching Strategies in NestJS: Cache-Aside, Invalidation, and the Traps

July 19, 2026·10 min read
RedisNestJSCachingPerformance
Share:
Redis Caching Strategies in NestJS: Cache-Aside, Invalidation, and the Traps

"Just add Redis" is the backend equivalent of "just add more RAM" — the suggestion that ends every performance discussion and starts most caching incidents. I say that as someone who reaches for Redis constantly. A well-placed cache is the cheapest performance win in backend engineering: a query that costs 40ms in Postgres comes back in under 2ms from Redis, and your database gets to spend its capacity on writes that actually matter.

But a cache is also a second copy of your data with its own lifecycle, and every second copy is a chance to be wrong. Stale reads, stampedes that flatten your database at the worst moment, hot keys that turn Redis itself into the bottleneck — I've hit all of them. This post is the guide I wish I'd had: the three core strategies, how to pick TTLs, how to invalidate without losing your mind, and the traps that make caching a net negative.

The three strategies

Every caching setup is a variation on one of three patterns, and the differences are entirely about who writes to the cache and when.

Cache-aside (lazy loading). The application checks the cache first; on a miss it reads the database, then writes the result into the cache. The cache is passive — it only ever holds what's been asked for.

Write-through. Every database write also writes the cache, synchronously, in the same code path. Reads almost always hit, because the cache is updated the moment data changes.

Write-behind (write-back). Writes go to the cache first and are flushed to the database asynchronously, in batches. Blazing write throughput, and the trap of the three: until the flush happens, Redis is your system of record. A crashed Redis means lost writes.

Cache-aside Write-through Write-behind
Read on miss App loads DB, fills cache Rare — cache pre-filled by writes Rare
Write path DB only (cache invalidated) DB + cache, synchronous Cache first, DB async
Consistency Stale window up to TTL Strong-ish (dual-write races aside) Eventually consistent
Write latency Unaffected Slower (two writes) Fastest
Failure mode Cold cache = miss storm Dual-write can diverge Data loss if cache dies
Complexity Low Medium High
My usage ~90% of cases Read-heavy, staleness-intolerant Almost never — counters, telemetry

Cache-aside is my default, and honestly it should be yours: it's the only one where the cache is purely an optimization. If Redis disappears, the app gets slower but stays correct. With the other two, Redis failures become correctness problems. Write-behind in particular I reserve for data I can afford to lose — view counters, not orders. If you're processing payments, the cache belongs nowhere near the write path — that's transaction and idempotency territory.

Cache-aside in NestJS, concretely

NestJS ships @nestjs/cache-manager, which wraps any store behind a simple interface. Wire it to Redis with Keyv:

// app.module.ts
import { CacheModule } from '@nestjs/cache-manager';
import KeyvRedis from '@keyv/redis';

@Module({
  imports: [
    CacheModule.registerAsync({
      isGlobal: true,
      useFactory: () => ({
        stores: [new KeyvRedis('redis://localhost:6379')],
        ttl: 60_000, // default TTL, ms
      }),
    }),
  ],
})
export class AppModule {}

For read-only endpoints, the built-in CacheInterceptor gives you response caching in one decorator. But for anything real I write the cache-aside flow explicitly, because I want control over keys, TTLs, and invalidation:

@Injectable()
export class ProductsService {
  constructor(
    @Inject(CACHE_MANAGER) private cache: Cache,
    private repo: ProductRepository,
  ) {}

  async findOne(id: string): Promise<Product> {
    const key = `product:v2:${id}`;

    const cached = await this.cache.get<Product>(key);
    if (cached) return cached;

    const product = await this.repo.findOneByOrFail({ id });
    await this.cache.set(key, product, 300_000); // 5 min
    return product;
  }

  async update(id: string, dto: UpdateProductDto): Promise<Product> {
    const product = await this.repo.save({ id, ...dto });
    await this.cache.del(`product:v2:${id}`); // invalidate, don't update
    return product;
  }
}

Two deliberate choices in there. First, on update I delete the cache entry rather than overwrite it — deleting is race-safe (the next read repopulates from the source of truth), while two concurrent updates writing the cache directly can leave the older value in place. Second, that v2 in the key: bump it when the cached shape changes, and every stale entry from the old schema becomes instantly unreachable. Cheapest mass-invalidation there is.

When I need Redis features cache-manager doesn't expose — SET NX locks, pipelines, pub/sub — I inject an ioredis client alongside it rather than fighting the abstraction.

Choosing TTLs

There's no formula, but there is a question: how stale can this data be before someone notices or something breaks? My rough bands:

  • 30–60 seconds — dashboards, feeds, listing pages. Users tolerate a minute of lag without noticing.
  • 5–15 minutes — entity-by-id lookups where you also invalidate on write. The TTL isn't the freshness mechanism here; it's the safety net for missed invalidations.
  • Hours to a day — reference data: countries, currencies, plans, feature config.
  • No caching — anything used in an authorization decision or a financial calculation. A stale permission check is a security bug with a TTL.

The pairing in the second band is the one that matters: event-driven invalidation for correctness, TTL for insurance. Relying on TTL alone means your staleness window is your TTL; relying on invalidation alone means one missed del leaves a wrong value cached forever.

Invalidation strategies

The famous hard problem. Three approaches, in the order I reach for them:

Key naming discipline. Deterministic, prefix-structured keys — product:v2:{id}, user:{id}:orders — so the write path can always reconstruct exactly which keys to delete. This covers most CRUD services. One warning: don't KEYS product:* in production to find them — it's O(N) over the whole keyspace and blocks Redis. If you need prefix deletion, use SCAN in batches.

Tag-based invalidation. When one entity appears in many cached results (a product in twenty cached category listings), track memberships in a Redis set as you cache, then delete the whole set's members on write:

async cacheWithTags(key: string, value: unknown, tags: string[], ttl: number) {
  await this.cache.set(key, value, ttl);
  await Promise.all(tags.map((t) => this.redis.sadd(`tag:${t}`, key)));
}

async invalidateTag(tag: string) {
  const keys = await this.redis.smembers(`tag:${tag}`);
  if (keys.length) await this.redis.del(...keys, `tag:${tag}`);
}

Event-driven invalidation. In a microservices architecture the writer often isn't the service holding the cache. Orders service updates a product's stock; catalog service has it cached. The writer emits product.updated, and every caching service subscribes and deletes its own keys. The caveat: your staleness window is now your event pipeline's lag, so measure that lag rather than assuming it's zero.

The stampede problem

Here's the failure that takes systems down. A popular key expires. In the next 50ms, 500 requests all miss, and all 500 dutifully run the same expensive query against Postgres — the exact query the cache existed to protect it from. This is the thundering herd, and the busier your service is, the harder it hits.

Three defenses, and they stack:

Per-key locks. One request rebuilds; everyone else briefly waits and re-reads:

async getWithLock<T>(key: string, load: () => Promise<T>, ttl: number): Promise<T> {
  const cached = await this.cache.get<T>(key);
  if (cached) return cached;

  const token = randomUUID();
  const locked = await this.redis.set(`lock:${key}`, token, 'PX', 5000, 'NX');

  if (!locked) {
    await sleep(80); // someone else is rebuilding — wait and retry once
    return (await this.cache.get<T>(key)) ?? load();
  }

  try {
    const fresh = await load();
    await this.cache.set(key, fresh, ttl);
    return fresh;
  } finally {
    // release only if it's still our lock
    await this.redis.eval(RELEASE_IF_TOKEN_MATCHES, 1, `lock:${key}`, token);
  }
}

TTL jitter. If you cache 10,000 keys with exactly ttl: 300_000 after a deploy, they all expire in the same instant five minutes later — a synchronized stampede you scheduled yourself. Add ±10% randomness: ttl * (0.9 + Math.random() * 0.2).

Stale-while-revalidate. Store the payload with its own logical expiry inside a longer physical TTL. When the logical expiry passes, serve the stale value immediately and refresh in the background. Latency stays flat through every refresh; the cost is one stale-window read, which for most read paths is a fine trade.

For most services, jitter plus locks on the handful of provably expensive keys is enough. SWR earns its complexity on high-traffic endpoints where even a brief coordinated wait would be visible in p99.

When caching makes things worse

The honest section. A cache is not free, and there are workloads where it's a pure loss:

  • Low hit rates. Every miss pays for both the Redis round-trip and the database query — slower than no cache at all. Long-tail access patterns (millions of keys, each read once or twice before its TTL lapses) produce 20% hit rates, and at 20% you're mostly paying overhead for staleness risk. Measure before assuming.
  • Hot keys. One celebrity product, one viral post — a single key taking 100k reads/sec lands on one Redis node no matter how big your cluster is, because clustering shards by key. Fixes: an in-process LRU in front of Redis for the hottest few entries, or key replication (product:42:#1..#4) with random read fan-out.
  • Caching cheap queries. A 2ms primary-key lookup gains almost nothing from a 1ms cache hit and inherits all the invalidation liability. Cache the expensive aggregate, not every findOne.
  • Big values. Serializing a 2MB object graph in and out of Redis on every request burns CPU and network on both ends. Cache the pieces, or cache the final rendered response — not the intermediate blob.

Measure it or it didn't happen

The metric that justifies a cache's existence is the hit rate, and it costs almost nothing to track:

// on every lookup
this.metrics.increment(hit ? 'cache.hit' : 'cache.miss', { keyspace: 'product' });

Tag by keyspace — an aggregate number across all keys hides exactly the low-performing keyspace you need to find. Server-side, redis-cli INFO stats gives you keyspace_hits / keyspace_misses for a global sanity check, and MEMORY USAGE <key> plus INFO memory tell you what those hits cost in RAM.

My rule of thumb: above ~80% hits on a query that's meaningfully expensive, the cache is earning its keep. Below 50%, either fix the TTL/key design or delete the cache — and I mean actually delete it. A cache that isn't measurably paying rent is just a staleness bug you haven't met yet.

Wrapping up

Caching with Redis in NestJS is mechanically easy — CacheModule, a get, a set, done in an afternoon. The engineering is in everything around it: choosing cache-aside so Redis stays an optimization instead of a dependency, deleting instead of updating on writes, versioned keys, TTLs paired with event-driven invalidation, jitter and locks before the stampede finds you, and a hit-rate dashboard so you know the thing is actually working.

Start with the boring version: cache-aside, explicit keys, modest TTLs, metrics from day one. Add tags, SWR, and the fancier machinery only when a measured problem demands it. The best caching layers I've operated are the ones dull enough that nobody thinks about them — which is exactly how anything holding a second copy of your data should behave.