Part of our Performance & Scalability series
Read the complete guideDatabase queries are the most common performance bottleneck in web applications. A poorly indexed query that takes 200ms, called on every page load for 1,000 concurrent users, generates 200 seconds of database CPU per second — a death spiral. Redis is the antidote: a sub-millisecond in-memory store that absorbs the read load that would otherwise hit your database repeatedly for identical data.
But caching is not just "put things in Redis." The wrong pattern causes stale data bugs, thundering herd disasters, or unbounded memory growth. This guide covers the four canonical caching patterns, cache invalidation strategies, stampede prevention, and production NestJS implementation so you cache correctly from the start.
Key Takeaways
- Cache-aside (lazy loading) is the safest default — only cache what is actually requested
- Write-through keeps cache and database in sync but adds write latency — use for frequently-read, infrequently-written data
- Cache stampede (thundering herd) destroys databases when a popular key expires — use probabilistic early expiration or mutex locks
- TTL must be set on every key — unbounded Redis growth is an incident waiting to happen
- Cache invalidation is hard: prefer short TTLs and event-driven invalidation over trying to invalidate perfectly
- Cache keys must include all query dimensions:
contacts:{orgId}:{page}:{limit}:{search}:{sortField}:{sortDir}- Use Redis namespaces and key patterns to enable bulk invalidation
- Monitor cache hit rate; below 80% means your TTLs are too short or your key structure is wrong
Redis Setup in NestJS
pnpm add ioredis @nestjs-modules/ioredis
// src/modules/cache/cache.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { InjectRedis } from '@nestjs-modules/ioredis';
import Redis from 'ioredis';
@Injectable()
export class CacheService {
private readonly logger = new Logger(CacheService.name);
constructor(@InjectRedis() private readonly redis: Redis) {}
async get<T>(key: string): Promise<T | null> {
const value = await this.redis.get(key);
if (!value) return null;
return JSON.parse(value) as T;
}
async set<T>(key: string, value: T, ttlSeconds = 300): Promise<void> {
await this.redis.set(key, JSON.stringify(value), 'EX', ttlSeconds);
}
async del(key: string): Promise<void> {
await this.redis.del(key);
}
// Use SCAN instead of KEYS to avoid blocking Redis on large key spaces
async invalidatePattern(pattern: string): Promise<void> {
let cursor = '0';
do {
const [nextCursor, keys] = await this.redis.scan(
cursor,
'MATCH', pattern,
'COUNT', 100
);
cursor = nextCursor;
if (keys.length > 0) {
await this.redis.del(...keys);
}
} while (cursor !== '0');
}
async remember<T>(
key: string,
ttlSeconds: number,
factory: () => Promise<T>
): Promise<T> {
const cached = await this.get<T>(key);
if (cached !== null) return cached;
const value = await factory();
await this.set(key, value, ttlSeconds);
return value;
}
}
// src/modules/cache/cache.module.ts
import { Global, Module } from '@nestjs/common';
import { RedisModule } from '@nestjs-modules/ioredis';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { CacheService } from './cache.service';
@Global()
@Module({
imports: [
RedisModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
type: 'single',
options: {
host: config.get('REDIS_HOST', 'localhost'),
port: config.get<number>('REDIS_PORT', 6379),
password: config.get('REDIS_PASSWORD'),
db: config.get<number>('REDIS_DB', 0),
keyPrefix: 'ecosire:',
},
}),
}),
],
providers: [CacheService],
exports: [CacheService],
})
export class AppCacheModule {}
Pattern 1: Cache-Aside (Lazy Loading)
The most common pattern. Read from cache; on miss, read from database and populate cache.
// contacts.service.ts
async findAll(orgId: string, page: number, limit: number, search?: string) {
const cacheKey = `contacts:${orgId}:${page}:${limit}:${search ?? 'all'}`;
return this.cache.remember(cacheKey, 300, async () => {
const results = await this.db.query.contacts.findMany({
where: and(
eq(contacts.organizationId, orgId),
search
? or(
ilike(contacts.name, `%${search}%`),
ilike(contacts.email, `%${search}%`)
)
: undefined
),
limit,
offset: (page - 1) * limit,
orderBy: desc(contacts.createdAt),
});
return results;
});
}
Pros: Simple; cache only contains what is actually requested. Cons: First request is always slow (cache miss); data can be stale until TTL expires.
When to invalidate: After create/update/delete, delete all keys matching contacts:{orgId}:*.
Pattern 2: Write-Through
Update cache simultaneously with the database on every write. Trades write latency for read consistency.
async update(orgId: string, contactId: string, dto: UpdateContactDto) {
// 1. Update the database
const [updated] = await this.db
.update(contacts)
.set({ ...dto, updatedAt: new Date() })
.where(
and(
eq(contacts.id, contactId),
eq(contacts.organizationId, orgId)
)
)
.returning();
// 2. Update the individual contact cache immediately (write-through)
const singleKey = `contact:${orgId}:${contactId}`;
await this.cache.set(singleKey, updated, 3600);
// 3. Invalidate list caches (they now contain stale order/counts)
await this.cache.invalidatePattern(`contacts:${orgId}:*`);
return updated;
}
When to use: Frequently-read, infrequently-written records (user profiles, settings, product catalog).
Pattern 3: Cache Stampede Prevention
When a popular cache key expires, hundreds of concurrent requests all miss the cache simultaneously and all hit the database — the "thundering herd." The database collapses under the sudden load.
Mutex Lock Pattern
Only one request rebuilds the cache; others wait and retry:
// cache.service.ts — mutex-protected cache fetch
async getWithLock<T>(
key: string,
ttlSeconds: number,
factory: () => Promise<T>
): Promise<T> {
// Try cache first
const cached = await this.get<T>(key);
if (cached !== null) return cached;
const lockKey = `lock:${key}`;
const lockOwner = `${process.pid}-${Date.now()}`;
// Try to acquire lock: NX = only set if not exists, EX = expire in 30s
const acquired = await this.redis.set(lockKey, lockOwner, 'NX', 'EX', 30);
if (acquired === 'OK') {
try {
const value = await factory();
await this.set(key, value, ttlSeconds);
return value;
} finally {
// Atomic check-and-delete: only release if we still own the lock
// Uses Lua defined with defineCommand for atomicity
const currentOwner = await this.redis.get(lockKey);
if (currentOwner === lockOwner) {
await this.redis.del(lockKey);
}
}
} else {
// Another process is rebuilding — wait 100ms and retry
await new Promise<void>((resolve) => {
const timer = setTimeout(resolve, 100);
// Store timer reference so Node.js does not block on it
timer.unref?.();
});
return this.getWithLock(key, ttlSeconds, factory);
}
}
Probabilistic Early Expiration (XFetch)
Start rebuilding the cache probabilistically before it expires, spreading recomputation load:
async getWithEarlyExpiration<T>(
key: string,
ttlSeconds: number,
factory: () => Promise<T>,
beta = 1 // Higher = more aggressive early recomputation
): Promise<T> {
const metaKey = `${key}:meta`;
const meta = await this.get<{ createdAt: number; ttl: number }>(metaKey);
const value = await this.get<T>(key);
if (value !== null && meta) {
const elapsed = Date.now() / 1000 - meta.createdAt;
const remaining = meta.ttl - elapsed;
// XFetch: trigger early recomputation probabilistically
const shouldRecompute = -Math.log(Math.random()) * beta > remaining;
if (!shouldRecompute) return value;
}
const newValue = await factory();
const now = Date.now() / 1000;
await this.set(key, newValue, ttlSeconds);
await this.set(metaKey, { createdAt: now, ttl: ttlSeconds }, ttlSeconds + 60);
return newValue;
}
Pattern 4: Tag-Based Cache Invalidation
Group cache keys by logical tags and invalidate an entire tag at once:
// Tag-based invalidation using Redis sets
async setWithTags(
key: string,
value: unknown,
ttlSeconds: number,
tags: string[]
): Promise<void> {
await this.set(key, value, ttlSeconds);
// Add key to each tag's member set using a pipeline
const pipeline = this.redis.pipeline();
for (const tag of tags) {
pipeline.sadd(`tag:${tag}`, key);
pipeline.expire(`tag:${tag}`, ttlSeconds + 60);
}
await pipeline.exec();
}
async invalidateByTag(tag: string): Promise<void> {
const keys = await this.redis.smembers(`tag:${tag}`);
if (keys.length > 0) {
const pipeline = this.redis.pipeline();
for (const cacheKey of keys) {
pipeline.del(cacheKey);
}
pipeline.del(`tag:${tag}`);
await pipeline.exec();
}
}
// Usage
await this.setWithTags(
`contacts:${orgId}:page:1`,
contacts,
300,
[`org:${orgId}`, 'contacts']
);
// After any contact mutation in org_123:
await this.invalidateByTag(`org:${orgId}`);
Session and Auth Caching
Redis is ideal for auth session storage — eliminate database lookups on every request:
// Cache user session data (role, permissions, orgId) for JWT validation
async cacheUserSession(userId: string, session: UserSession): Promise<void> {
const key = `session:${userId}`;
await this.redis.hset(key,
'id', session.id,
'email', session.email,
'role', session.role,
'organizationId', session.organizationId,
'tokenVersion', String(session.tokenVersion)
);
await this.redis.expire(key, 900); // 15 minutes — matches access token TTL
}
async getUserSession(userId: string): Promise<UserSession | null> {
const data = await this.redis.hgetall(`session:${userId}`);
if (!data || Object.keys(data).length === 0) return null;
return {
...data,
tokenVersion: parseInt(data.tokenVersion, 10),
} as UserSession;
}
async invalidateUserSession(userId: string): Promise<void> {
await this.redis.del(`session:${userId}`);
}
TTL Strategy by Data Type
| Data Type | Recommended TTL | Invalidation Strategy |
|---|---|---|
| User profile | 15 minutes | On profile update |
| Organization settings | 1 hour | On settings change |
| Product catalog | 24 hours | On product create/update |
| Blog post list | 10 minutes | On post publish |
| API rate limit counter | Per window (60s) | Automatic expiry |
| Auth session | 15 minutes | On logout or token revoke |
| Search results | 5 minutes | TTL only |
| Aggregated metrics | 1 minute | TTL only |
| One-time codes (auth) | 60 seconds | After use or expiry |
| Paginated list query | 5 minutes | On any mutation in org |
Monitoring Cache Performance
// Add hit/miss tracking to CacheService
async get<T>(key: string): Promise<T | null> {
const value = await this.redis.get(key);
if (value) {
await this.redis.incr('metrics:cache_hits');
return JSON.parse(value) as T;
}
await this.redis.incr('metrics:cache_misses');
return null;
}
// Report hit rate every 5 minutes
@Cron('*/5 * * * *')
async reportCacheMetrics(): Promise<void> {
const [hits, misses] = await this.redis.mget(
'metrics:cache_hits',
'metrics:cache_misses'
);
const h = parseInt(hits ?? '0', 10);
const m = parseInt(misses ?? '0', 10);
const total = h + m;
const hitRate = total === 0 ? 1 : h / total;
if (hitRate < 0.8) {
this.logger.warn(`Low cache hit rate: ${(hitRate * 100).toFixed(1)}%`);
}
// Reset counters
await this.redis.set('metrics:cache_hits', '0');
await this.redis.set('metrics:cache_misses', '0');
}
Key Redis metrics to monitor via redis-cli INFO stats:
keyspace_hits/keyspace_misses— global hit rate (target over 80%)used_memory— watch for unbounded growthevicted_keys— keys evicted by maxmemory policy (should be near 0 for cache-aside)connected_clients— connection pool utilizationinstantaneous_ops_per_sec— current throughput
Frequently Asked Questions
What maxmemory policy should I use for a cache?
Use allkeys-lru (evict least recently used keys from all keys) or volatile-lru (evict LRU keys that have a TTL set). For a pure cache workload, allkeys-lru is standard — Redis automatically evicts cold keys when memory is full. Never use noeviction for cache — it returns errors when memory is full instead of evicting old data.
How do I avoid storing sensitive data in Redis?
Never cache raw passwords, private keys, payment card data, or SSNs. For auth sessions, cache minimal data: userId, role, organizationId, tokenVersion — not full JWT payloads or API credentials. Enable Redis AUTH and TLS in production. If your Redis instance is compromised, only metadata is exposed when you follow this discipline.
What is the difference between SCAN and KEYS for pattern deletion?
KEYS pattern is a blocking O(n) operation that pauses all Redis commands while scanning — it can cause seconds of downtime on large key spaces. SCAN is non-blocking, iterating in small chunks with a cursor. Always use SCAN for production pattern deletion. The tradeoff is that SCAN may not return all keys if they are added or deleted during the scan — acceptable for cache invalidation.
Should I use a separate Redis instance for caching vs rate limiting vs sessions?
For most applications, one Redis instance with different key prefixes is fine. Separate instances make sense when: the cache needs a different maxmemory-policy than the rate limit store (cache: allkeys-lru, rate limits: noeviction), or when you need independent scaling and failover. At scale, use Redis Cluster with separate clusters per workload type.
How do I handle cache invalidation for paginated list queries?
Paginated list caches are tricky — adding a contact on page 1 shifts all contacts on pages 2+. The pragmatic solution: use short TTLs (2-5 minutes) and invalidate all pages for the organization on any write using pattern invalidation (contacts:{orgId}:*). For large organizations with heavy write volumes, skip caching pagination entirely and rely on database-level optimization (proper indexes, covering indexes) instead.
How do I test cache behavior in Vitest?
Mock the CacheService in unit tests with vi.fn() returning appropriate values for hit/miss scenarios. For integration tests, use a real Redis instance (Docker) and use redis.flushdb() in beforeEach to start clean. Test both the cache-hit path (no database call) and cache-miss path (database called + cache populated) explicitly.
Next Steps
Redis caching is one of the highest-ROI performance investments in a web application. The difference between a 200ms database query and a 2ms Redis hit is 100x — at scale, that translates directly into infrastructure cost savings and user experience improvement.
ECOSIRE implements Redis caching with cache-aside, write-through invalidation, stampede prevention, and cache hit rate monitoring on every NestJS project. Explore our backend engineering services to learn how we design performant, scalable APIs.
Written by
ECOSIRE TeamTechnical Writing
The ECOSIRE technical writing team covers Odoo ERP, Shopify eCommerce, AI agents, Power BI analytics, GoHighLevel automation, and enterprise software best practices. Our guides help businesses make informed technology decisions.
ECOSIRE
Grow Your Business with ECOSIRE
Enterprise solutions across ERP, eCommerce, AI, analytics, and automation.
Related Articles
Shopify Speed Optimization: A Technical Checklist That Actually Moves Core Web Vitals (2026)
A field-tested Shopify speed checklist for 2026 — what actually improves LCP, INP, and CLS on real stores, what wastes time, and how to audit apps and themes.
Odoo 19 HR: Skills Matrix, Career Plans, Performance Cycles
Odoo 19 HR upgrade: native skills matrix, career path planning, performance review cycles, 9-box grid, succession planning, HRIS integration.
Odoo 19 Performance Benchmarks: PostgreSQL 17 Tuning Numbers
Real-world Odoo 19 performance benchmarks: web client speed, ORM throughput, PG17 tuning settings, connection pooling, worker counts, scaling thresholds.
More from Performance & Scalability
Shopify Speed Optimization: A Technical Checklist That Actually Moves Core Web Vitals (2026)
A field-tested Shopify speed checklist for 2026 — what actually improves LCP, INP, and CLS on real stores, what wastes time, and how to audit apps and themes.
Technical SEO Audit Checklist 2026: 47 Checks We Run on Every Client Site
The 47-point technical SEO audit checklist we run on every client site in 2026 — crawlability, indexation, canonicals, hreflang, Core Web Vitals, and logs.
Odoo 19 HR: Skills Matrix, Career Plans, Performance Cycles
Odoo 19 HR upgrade: native skills matrix, career path planning, performance review cycles, 9-box grid, succession planning, HRIS integration.
Odoo 19 Performance Benchmarks: PostgreSQL 17 Tuning Numbers
Real-world Odoo 19 performance benchmarks: web client speed, ORM throughput, PG17 tuning settings, connection pooling, worker counts, scaling thresholds.
OpenClaw Cost Optimization and Token Efficiency at Scale
OpenClaw token cost optimization: prompt caching, model routing, response caching, batch APIs, and per-tenant cost guardrails for production agents.
Power BI Incremental Refresh for Tables Over 10 Million Rows
Power BI Incremental Refresh playbook for 10M+ row tables: partition design, RangeStart/RangeEnd, refresh policies, query folding, and DirectQuery hybrids.