Part of our Performance & Scalability series
Read the complete guideRedis Caching Patterns for Web Applications
Database 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 Research and Development Team
Building enterprise-grade digital products at ECOSIRE. Sharing insights on Odoo integrations, e-commerce automation, and AI-powered business solutions.
Related Articles
API Rate Limiting: Patterns and Best Practices
Master API rate limiting with token bucket, sliding window, and fixed counter patterns. Protect your backend with NestJS throttler, Redis, and real-world configuration examples.
k6 Load Testing: Stress-Test Your APIs Before Launch
Master k6 load testing for Node.js APIs. Covers virtual user ramp-ups, thresholds, scenarios, HTTP/2, WebSocket testing, Grafana dashboards, and CI integration patterns.
NestJS 11 Enterprise API Patterns
Master NestJS 11 enterprise patterns: guards, interceptors, pipes, multi-tenancy, and production-ready API design for scalable backend systems.
More from Performance & Scalability
k6 Load Testing: Stress-Test Your APIs Before Launch
Master k6 load testing for Node.js APIs. Covers virtual user ramp-ups, thresholds, scenarios, HTTP/2, WebSocket testing, Grafana dashboards, and CI integration patterns.
Nginx Production Configuration: SSL, Caching, and Security
Nginx production configuration guide: SSL termination, HTTP/2, caching headers, security headers, rate limiting, reverse proxy setup, and Cloudflare integration patterns.
Odoo Performance Tuning: PostgreSQL and Server Optimization
Expert guide to Odoo 19 performance tuning. Covers PostgreSQL configuration, indexing, query optimization, Nginx caching, and server sizing for enterprise deployments.
Odoo vs Acumatica: Cloud ERP for Growing Businesses
Odoo vs Acumatica compared for 2026: unique pricing models, scalability, manufacturing depth, and which cloud ERP fits your growth trajectory.
Testing and Monitoring AI Agents in Production
A complete guide to testing and monitoring AI agents in production environments. Covers evaluation frameworks, observability, drift detection, and incident response for OpenClaw deployments.
Compliance Monitoring Agents with OpenClaw
Deploy OpenClaw AI agents for continuous compliance monitoring. Automate regulatory checks, policy enforcement, audit trail generation, and compliance reporting.