Back to Blog

Redis Caching Strategy for SaaS — What We Cache, What We Don't, and Why

Published: June 23, 2026
Redis Caching Strategy for SaaS — What We Cache, What We Don't, and Why

Caching is easy to add and expensive to get wrong. Add Redis, point your NestJS cache module at it, and the dashboard loads faster. Until the first time you serve stale data to a paying customer, cache the wrong thing and never notice it is stale, or invalidate a key too aggressively and nuke your hit rate.

A redis caching strategy needs to answer four questions: what to cache, what not to cache, how long to keep it, and how to know when it is stale. Most tutorials cover the first and skip the rest. Here is our playbook for all four.

Green electronic memory module on a white background representing in-memory caching with Redis

What Is Worth Caching in a SaaS Product

Not everything should be cached. The cost of caching — stale data, invalidation complexity, Redis memory — should be lower than the cost of not caching. Here is the framework we use to decide:

Cache it if: read-to-write ratio is high (>10:1), the query is expensive (>50ms), the data can be stale for seconds without harm, or the same data is requested by many users.

Do not cache it if: the data must be immediately consistent (payment balances), it is user-specific and queried once per session, it contains secrets or PII, or the query is already fast (<10ms) with low volume.

The rule: caching a fast query is technical debt — you manage invalidation and staleness for zero benefit.

What Should Never Be Cached

We learned this the hard way when a cached user object — complete with a hashed password — sat in Redis for six hours after the password changed. The auth endpoint returned a 200 because the cached hash matched the old password. - Credentials and auth tokens. Auth sessions belong in a dedicated store flushed on logout.

  • Financial balances. A cached balance showing $100 when the real balance is $50 is a support ticket.
  • PCI-scoped data. Card data must not touch Redis if it is not PCI-compliant.
  • Rate-limited resources. Cached "denied" responses persist past rate limit resets.

Cache-Aside Pattern Implementation in NestJS

The cache-aside pattern is the standard: check cache, return if hit, query database on miss, store in cache, return. NestJS's @nestjs/cache-manager package handles the Redis integration:

TypeScript
1// src/app.module.ts
2import { Module } from '@nestjs/common';
3import { CacheModule } from '@nestjs/cache-manager';
4import { createKeyv } from '@keyv/redis';
5
6@Module({
7  imports: [
8    CacheModule.registerAsync({
9      useFactory: () => ({
10        stores: [createKeyv('redis://localhost:6379')],
11      }),
12    }),
13  ],
14})
15export class AppModule {}

The cache manager is injected into any service. Here is the cache-aside pattern for a SaaS dashboard panel that lists recent orders:

TypeScript
1// src/dashboard/dashboard.service.ts
2import { Injectable, Inject } from '@nestjs/common';
3import { CACHE_MANAGER } from '@nestjs/cache-manager';
4import { Cache } from 'cache-manager';
5import { PrismaService } from '../prisma/prisma.service';
6
7@Injectable()
8export class DashboardService {
9  constructor(
10    @Inject(CACHE_MANAGER) private cacheManager: Cache,
11    private prisma: PrismaService,
12  ) {}
13
14  async getRecentOrders(tenantId: string): Promise<Order[]> {
15    const cacheKey = `${tenantId}:dashboard:recent-orders`;
16
17    const cached = await this.cacheManager.get<Order[]>(cacheKey);
18    if (cached) return cached;
19
20    const orders = await this.prisma.order.findMany({
21      where: { tenantId, status: 'active' },
22      orderBy: { createdAt: 'desc' },
23      take: 20,
24    });
25
26    await this.cacheManager.set(cacheKey, orders, 300_000); // 5 min TTL
27    return orders;
28  }
29}

The pattern is straightforward. The hard part is deciding the TTL, designing the key, and knowing when to invalidate. The NestJS official caching documentation covers the basic setup, and the ioredis library handles the Redis driver, but both stop at "here is how to connect." The rest of this post is what happens after you connect.

TTL Strategy: How Long Should Different Data Types Be Cached

We use a tiered TTL strategy based on how quickly the data changes and how stale it can be without causing problems:

Data TypeTTLReasoning
User profile (name, avatar, preferences)5 minChanges infrequently, low cost of staleness
Product / pricing catalog6 hoursChanges rarely, expensive to query
Lookup tables (countries, tax rates)24 hoursEssentially static, high read frequency
Dashboard KPIs (revenue, user count)15 minReporting expectations tolerate delay
Feature flag evaluations60 secondsMust reflect recent changes quickly (see our feature flags post for the Redis-backed evaluation pattern)
Aggregated analytics reportsRefresh on scheduleNo TTL — controlled refresh via cron

The most common mistake is the same TTL for everything. A user profile cached for 6 hours means a name change takes 6 hours to appear. A product catalog cached for 5 minutes means Redis refetches data that barely moves.

Tag-Based Cache Invalidation

The hardest problem in caching is knowing when a cached value is stale. TTL alone is not enough — if a user updates their profile, waiting 5 minutes for the cache to expire means 5 minutes of stale data.

Tag-based invalidation solves this: each cache key is associated with one or more tags. When data changes, you invalidate all keys with the relevant tag:

TypeScript
1// src/cache/cache-tag.service.ts
2import { Injectable, Inject } from '@nestjs/common';
3import { CACHE_MANAGER } from '@nestjs/cache-manager';
4import { Cache } from 'cache-manager';
5
6@Injectable()
7export class CacheTagService {
8  constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {}
9
10  async getWithTag<T>(key: string, tags: string[]): Promise<T | undefined> {
11    return this.cacheManager.get<T>(key);
12  }
13
14  async setWithTag<T>(
15    key: string,
16    value: T,
17    ttl: number,
18    tags: string[],
19  ): Promise<void> {
20    await this.cacheManager.set(key, value, ttl);
21    // Store tag-to-key mappings for bulk invalidation
22    for (const tag of tags) {
23      const tagKey = `tag:${tag}`;
24      const keys = await this.cacheManager.get<string[]>(tagKey) ?? [];
25      if (!keys.includes(key)) {
26        keys.push(key);
27        await this.cacheManager.set(tagKey, keys, 0);
28      }
29    }
30  }
31
32  async invalidateByTag(tag: string): Promise<void> {
33    const tagKey = `tag:${tag}`;
34    const keys = await this.cacheManager.get<string[]>(tagKey);
35    if (keys) {
36      await Promise.all(keys.map((k) => this.cacheManager.del(k)));
37      await this.cacheManager.del(tagKey);
38    }
39  }
40}

When a user updates their profile, you invalidate the user:{id} tag and every cached value depending on that user is evicted in one call. (The Redis command reference covers the underlying operations.)

Avoiding Cache Stampede With Lock-Based Warming

A cache stampede happens when a hot key expires and 50 concurrent requests all hit the cache miss path simultaneously. All 50 query the database. The database spikes. Response times climb. Some requests timeout. The retries compound the problem.

The fix is a distributed lock around cache regeneration using SET NX:

TypeScript
1async getExpensiveDashboardReport(tenantId: string): Promise<Report> {
2  const cacheKey = `${tenantId}:dashboard:report`;
3  const lockKey = `lock:${cacheKey}`;
4  const cached = await this.cacheManager.get<Report>(cacheKey);
5  if (cached) return cached;
6  const lockAcquired = await this.redis.set(lockKey, 'locked', 'NX', 'EX', 30);
7  if (!lockAcquired) {
8    await new Promise((r) => setTimeout(r, 100));
9    return this.getExpensiveDashboardReport(tenantId);
10  }
11  try {
12    const recheck = await this.cacheManager.get<Report>(cacheKey);
13    if (recheck) return recheck;
14    const report = await this.generateReport(tenantId);
15    await this.cacheManager.set(cacheKey, report, 900_000);
16    return report;
17  } finally {
18    await this.redis.del(lockKey);
19  }
20}

One request acquires the lock and generates the data; the rest wait 100ms and retrieve the cached result. No stampede.

We use this pattern on one endpoint: the multi-tenant revenue dashboard that aggregates across thousands of orders. It prevents a stampede that used to spike the database to 80% CPU every 15 minutes when the key expired. We touch on similar patterns in our PostgreSQL performance post — the same "one generates, many wait" idea applies to cache warming just as it applies to batch report generation. The same Redis-backed BullMQ queue powers our background job architecture for scheduled cache refreshes.

Cache Key Design: Namespacing by Tenant

In a multi-tenant SaaS, cache keys must never leak data between tenants. The fix is prefixing every key with the tenant ID:

Code
1abc123:dashboard:recent-orders
2def456:user:42
3ghi789:product:catalog

This gives tenant isolation (same key in different tenants maps to different entries), bulk invalidation (delete all keys for a departing tenant), and debuggability (KEYS abc123:* shows what is cached). Never build cache keys from user-controlled input — a malicious tenant like *:admin:settings could match keys it should not see.

Monitoring Cache Hit Rate

You cannot tune what you do not measure. Redis exposes keyspace statistics that tell you exactly how effective your cache is:

Bash
1redis-cli info stats

Look for keyspace_hits and keyspace_misses. The hit rate is hits / (hits + misses). We log this to our monitoring system every minute:

TypeScript
1// src/cache/cache-monitor.service.ts
2import { Injectable } from '@nestjs/common';
3import Redis from 'ioredis';
4
5@Injectable()
6export class CacheMonitorService {
7  constructor(private readonly redis: Redis) {}
8
9  async logHitRate(): Promise<void> {
10    const info = await this.redis.info('stats');
11    const hits = parseInt(info.match(/keyspace_hits:(\d+)/)?.[1] ?? '0');
12    const misses = parseInt(info.match(/keyspace_misses:(\d+)/)?.[1] ?? '0');
13    const total = hits + misses;
14
15    if (total > 0) {
16      const rate = (hits / total * 100).toFixed(1);
17      console.log(`[Cache] Hit rate: ${rate}% (${hits}/${total})`);
18
19      // Alert if hit rate drops below threshold
20      if (parseFloat(rate) < 80) {
21        console.warn('[Cache] Hit rate below 80% — investigate TTLs or cache size');
22      }
23    }
24  }
25}

A hit rate below 80% generally means one of three things: TTLs are too short, the cache is too small for the working set, or you are caching data that nobody actually reads. In all three cases, the answer is not "add more Redis memory" — it is "cache less data with longer TTLs."

Conclusion

A good caching strategy is defined by what you deliberately skip, not what you cache. Per-data-type TTLs, tag-based invalidation, stampede locks, tenant-scoped keys, and hit-rate monitoring — these are the difference between a cache that helps and a cache that occasionally serves stale data.

We have made both mistakes: cached user objects with password hashes, used the same TTL for everything, skipped monitoring and found the cache was 90% misses when the Redis bill arrived. These patterns fix all of that. Apply them in order — decide what to cache, implement cache-aside, set per-type TTLs, add tag invalidation, protect hot keys, namespace by tenant, measure the hit rate.

If you are staring at a slow endpoint wondering whether Redis is the answer — measure the database query first. Most "we need caching" problems are indexing problems wearing a cache-shaped costume.

RAM sticks and microprocessors on a motherboard representing the hardware underlying Redis in-memory caching

Frequently Asked Questions

The cache-aside pattern checks the cache first before querying the database. On a cache miss, the application loads data from the database, stores it in the cache with a TTL, and returns it. On subsequent requests, the cache returns the data directly until it expires. NestJS implements this via the @nestjs/cache-manager package with a Redis store, typically wrapped in a service method that calls cacheManager.get() first, then falls back to the database repository if the key is missing.

Base TTL on how quickly the data changes and how stale it can be without causing problems. User profile data: 5-15 minutes. Product catalog: 1-6 hours. Reference/lookup data (country lists, tax rates): 24+ hours. Aggregated reporting data: refresh on schedule rather than by TTL. Session data: 30-60 minutes of inactivity. Never cache security-sensitive or user-specific financial data with long TTLs. When in doubt, start with a short TTL and extend it based on measured hit rates.

Use tag-based cache invalidation: assign every cache key one or more tags (e.g., 'user:123', 'orders'), and when data changes, invalidate all keys associated with the relevant tags. NestJS cache-manager supports this pattern through metadata stored alongside each cached value. For simpler setups, delete the specific key when the underlying record is updated. For broad changes (e.g., a bulk import), use a pattern-based scan (Redis SCAN) to find and delete matching keys.

A cache stampede occurs when a popular key expires and multiple concurrent requests all detect the cache miss simultaneously, each triggering an expensive database query or computation. This can overwhelm the database. Prevent it with lock-based warming: when a cache miss occurs, acquire a distributed lock via Redis SET NX, generate the data once, store it in cache, and release the lock. Competing requests wait briefly for the lock to resolve or serve stale data while the refresh happens.

Use a tenant-scoped key namespace: tenant_id:entity:identifier. For example, 'abc123:user:42' or 'abc123:dashboard:monthly-revenue'. This keeps keys from different tenants isolated, makes it easy to invalidate all keys for a specific tenant when they leave, and prevents accidental cross-tenant data leakage. Never include the tenant_id as a query parameter in the cached value — it must be part of the key itself.

Use the Redis INFO stats command to get keyspace_hits and keyspace_misses. Calculate hit rate as hits / (hits + misses). Log this metric periodically to your monitoring system. A hit rate below 80% suggests either TTLs are too short, the cache is too small for working set, or you are caching data that is rarely requested. Track per-endpoint hit rates separately by tagging cache keys by endpoint prefix.

Portrait of Umar Farooq

About Umar Farooq

Umar Farooq is the founder and lead engineer of Codify SaaS. He builds B2B SaaS products and web applications on modern TypeScript stacks and enterprise Java, and writes code-first guides drawn from real production work — the schema decisions, the migrations that almost went wrong, and the performance fixes that actually moved the numbers. When he recommends an approach, he shows the code and explains the trade-offs.

Read full bio