Redis Caching Strategy Design and Implementation
Design a Redis caching strategy to reduce database load and improve API response times by 10x or more, implementing Cache-Aside and Write-Through patterns with Node.js
Problem
Required Tools
An in-memory key-value data store. Provides millisecond read/write performance and supports various data structures (String, Hash, List, Set, Sorted Set).
A high-performance Redis client for Node.js. A production-level library supporting clusters, sentinel, and pipelining.
A GUI tool for visually exploring and monitoring Redis data. Provides memory analysis and slow log inspection.
A container orchestration tool for easily running Redis servers in local development environments.
Solution Steps
Install Redis and Understand Basic Data Types
Run Redis with Docker and connect with the ioredis client. Redis's 5 core data types (String, Hash, List, Set, Sorted Set) are each suited to different caching scenarios. String is for simple values, Hash for object caching, and Sorted Set for ranking data.
# Run Redis with Docker
docker run -d --name redis -p 6379:6379 redis:7-alpine \
redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
# Install ioredis in Node.js project
npm install ioredis
// redis-client.ts - Redis connection setup
import Redis from 'ioredis';
const redis = new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: parseInt(process.env.REDIS_PORT || '6379'),
password: process.env.REDIS_PASSWORD,
db: 0,
retryStrategy: (times) => Math.min(times * 50, 2000),
maxRetriesPerRequest: 3,
enableReadyCheck: true,
lazyConnect: true,
});
redis.on('error', (err) => console.error('Redis error:', err));
redis.on('connect', () => console.log('Redis connected'));
export default redis;
// Usage examples by data type
// String: Simple value caching
await redis.set('product:123', JSON.stringify(product), 'EX', 3600);
const cached = JSON.parse(await redis.get('product:123') || 'null');
// Hash: Per-field object caching (partial updates possible)
await redis.hset('user:456', { name: 'Kim', email: 'kim@test.com', role: 'admin' });
const name = await redis.hget('user:456', 'name');
const all = await redis.hgetall('user:456');
// Sorted Set: Popular product rankings
await redis.zadd('popular:products', 150, 'product:1', 89, 'product:2', 230, 'product:3');
const top10 = await redis.zrevrange('popular:products', 0, 9, 'WITHSCORES');Implement Cache-Aside Pattern (The Most Basic Caching Strategy)
Cache-Aside (Lazy Loading) is a pattern that queries the DB only when data is not in the cache, then stores it in the cache. It is optimal for read-heavy workloads and is resilient to failures since the DB can be queried directly if the cache goes down. Setting TTL (Time To Live) so cached data automatically expires helps manage data freshness.
// cache-aside.ts - Cache-Aside pattern implementation
import redis from './redis-client';
import { prisma } from './db';
// Generic Cache-Aside wrapper
async function cacheAside<T>(
key: string,
ttlSeconds: number,
fetchFn: () => Promise<T>
): Promise<T> {
// 1. Check cache first
const cached = await redis.get(key);
if (cached) {
return JSON.parse(cached) as T;
}
// 2. Cache miss -> query DB
const data = await fetchFn();
// 3. Store in cache (async - does not block response)
if (data) {
redis.set(key, JSON.stringify(data), 'EX', ttlSeconds).catch(console.error);
}
return data;
}
// Product detail lookup (with Cache-Aside)
async function getProduct(productId: string) {
return cacheAside(
`product:${productId}`,
3600, // 1-hour TTL
() => prisma.product.findUnique({
where: { id: productId },
include: { category: true, reviews: { take: 10 } },
})
);
}
// Category list (infrequently changing data)
async function getCategories() {
return cacheAside(
'categories:all',
86400, // 24-hour TTL
() => prisma.category.findMany({
orderBy: { sortOrder: 'asc' },
include: { _count: { select: { products: true } } },
})
);
}
// Popular products list (short TTL)
async function getPopularProducts(limit = 20) {
return cacheAside(
`popular:products:${limit}`,
300, // 5-minute TTL
() => prisma.product.findMany({
orderBy: { salesCount: 'desc' },
take: limit,
select: { id: true, name: true, price: true, imageUrl: true, salesCount: true },
})
);
}Cache Invalidation Strategy
When data changes, caches must be invalidated (deleted or updated) to maintain consistency. Write-Through updates the cache simultaneously with DB writes, while Write-Behind updates only the cache and syncs to DB asynchronously. Using unified key naming conventions makes it easy to batch-invalidate related caches.
// cache-invalidation.ts - Cache invalidation strategies
// 1. Single key invalidation
async function invalidateProduct(productId: string) {
await redis.del(`product:${productId}`);
}
// 2. Pattern-based batch invalidation (using SCAN)
async function invalidateByPattern(pattern: string) {
let cursor = '0';
do {
const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
cursor = nextCursor;
if (keys.length > 0) {
await redis.del(...keys);
}
} while (cursor !== '0');
}
// 3. Write-Through pattern (DB + cache simultaneous update)
async function updateProduct(productId: string, data: any) {
// Update DB
const product = await prisma.product.update({
where: { id: productId },
data,
include: { category: true },
});
// Immediately update cache (update, not delete)
await redis.set(`product:${productId}`, JSON.stringify(product), 'EX', 3600);
// Invalidate related caches too
await invalidateByPattern('popular:products:*');
await invalidateByPattern(`category:${product.categoryId}:*`);
return product;
}
// 4. Tag-based cache group invalidation
async function cacheWithTags(key: string, tags: string[], data: any, ttl: number) {
const pipeline = redis.pipeline();
pipeline.set(key, JSON.stringify(data), 'EX', ttl);
for (const tag of tags) {
pipeline.sadd(`tag:${tag}`, key);
pipeline.expire(`tag:${tag}`, ttl + 60);
}
await pipeline.exec();
}
async function invalidateByTag(tag: string) {
const keys = await redis.smembers(`tag:${tag}`);
if (keys.length > 0) {
await redis.del(...keys, `tag:${tag}`);
}
}
// Usage: Tag products cache
await cacheWithTags(
`product:${productId}`,
[`category:${categoryId}`, 'products', 'homepage'],
productData,
3600
);
// On category change, invalidate all related product caches
await invalidateByTag(`category:${categoryId}`);Cache Stampede Prevention and Distributed Locks
When a cache expires, multiple requests simultaneously querying the DB causes the Cache Stampede (Thundering Herd) problem. A distributed lock ensures only one request queries the DB while others wait for the cache to be refreshed. The Stale-While-Revalidate pattern immediately returns expired cache data while refreshing in the background.
// cache-stampede.ts - Cache Stampede prevention
// Method 1: Distributed lock to prevent concurrent DB queries
async function cacheAsideWithLock<T>(
key: string,
ttl: number,
fetchFn: () => Promise<T>,
lockTimeout = 5000
): Promise<T | null> {
// Check cache
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
// Attempt to acquire lock
const lockKey = `lock:${key}`;
const lockAcquired = await redis.set(lockKey, '1', 'PX', lockTimeout, 'NX');
if (lockAcquired) {
try {
// Lock acquired -> query DB and refresh cache
const data = await fetchFn();
if (data) {
await redis.set(key, JSON.stringify(data), 'EX', ttl);
}
return data;
} finally {
await redis.del(lockKey);
}
} else {
// Lock not acquired -> wait for cache refresh
for (let i = 0; i < 50; i++) {
await new Promise(resolve => setTimeout(resolve, 100));
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
}
// Timeout -> direct DB query (fallback)
return fetchFn();
}
}
// Method 2: Stale-While-Revalidate (return stale + background refresh)
async function cacheStaleWhileRevalidate<T>(
key: string,
ttl: number,
staleTtl: number,
fetchFn: () => Promise<T>
): Promise<T | null> {
const cached = await redis.get(key);
const meta = await redis.get(`meta:${key}`);
if (cached) {
// If meta (actual TTL) expired, refresh in background
if (!meta) {
refreshCache(key, ttl, staleTtl, fetchFn).catch(console.error);
}
return JSON.parse(cached);
}
// No cache -> synchronous query
const data = await fetchFn();
if (data) {
const pipeline = redis.pipeline();
pipeline.set(key, JSON.stringify(data), 'EX', ttl + staleTtl);
pipeline.set(`meta:${key}`, '1', 'EX', ttl);
await pipeline.exec();
}
return data;
}
async function refreshCache<T>(key: string, ttl: number, staleTtl: number, fetchFn: () => Promise<T>) {
const lockKey = `refresh:${key}`;
const acquired = await redis.set(lockKey, '1', 'PX', 10000, 'NX');
if (!acquired) return;
try {
const data = await fetchFn();
if (data) {
const pipeline = redis.pipeline();
pipeline.set(key, JSON.stringify(data), 'EX', ttl + staleTtl);
pipeline.set(`meta:${key}`, '1', 'EX', ttl);
await pipeline.exec();
}
} finally {
await redis.del(lockKey);
}
}Redis Session Store and Rate Limiting
Storing session data in Redis enables session sharing across server instances for horizontal scaling. Combining Redis's INCR command with TTL enables building a simple Rate Limiter. The Sliding Window algorithm allows more precise request limiting.
// session-and-ratelimit.ts
// 1. Redis session store (Express)
import session from 'express-session';
import RedisStore from 'connect-redis';
app.use(session({
store: new RedisStore({ client: redis, prefix: 'sess:' }),
secret: process.env.SESSION_SECRET!,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 24 * 60 * 60 * 1000, // 24 hours
},
}));
// 2. Rate Limiter (Fixed Window)
async function rateLimit(
identifier: string,
maxRequests: number,
windowSeconds: number
): Promise<{ allowed: boolean; remaining: number; resetIn: number }> {
const key = `ratelimit:${identifier}:${Math.floor(Date.now() / 1000 / windowSeconds)}`;
const pipeline = redis.pipeline();
pipeline.incr(key);
pipeline.expire(key, windowSeconds);
const results = await pipeline.exec();
const current = results![0][1] as number;
const remaining = Math.max(0, maxRequests - current);
const resetIn = windowSeconds - (Math.floor(Date.now() / 1000) % windowSeconds);
return {
allowed: current <= maxRequests,
remaining,
resetIn,
};
}
// Use as Express middleware
async function rateLimitMiddleware(req: any, res: any, next: any) {
const identifier = req.ip || req.headers['x-forwarded-for'];
const result = await rateLimit(identifier, 100, 60); // 100 per minute
res.set('X-RateLimit-Remaining', String(result.remaining));
res.set('X-RateLimit-Reset', String(result.resetIn));
if (!result.allowed) {
return res.status(429).json({ error: 'Too many requests' });
}
next();
}
// 3. Sliding Window Rate Limiter (more precise limiting)
async function slidingWindowRateLimit(
identifier: string,
maxRequests: number,
windowMs: number
): Promise<boolean> {
const key = `sliding:${identifier}`;
const now = Date.now();
const windowStart = now - windowMs;
const pipeline = redis.pipeline();
pipeline.zremrangebyscore(key, '-inf', windowStart);
pipeline.zadd(key, now, `${now}:${Math.random()}`);
pipeline.zcard(key);
pipeline.expire(key, Math.ceil(windowMs / 1000));
const results = await pipeline.exec();
const count = results![2][1] as number;
return count <= maxRequests;
}Redis Monitoring and Memory Optimization
Monitor key metrics like memory usage, hit rate, and connection count using the Redis INFO command. Setting maxmemory-policy to allkeys-lru automatically deletes the oldest keys when memory is full. Maintaining consistent key naming conventions, serialization methods, and TTL strategies improves memory efficiency.
# Redis status monitoring
redis-cli INFO stats | grep -E "keyspace_hits|keyspace_misses|used_memory_human"
# Calculate cache hit rate
# hit_rate = keyspace_hits / (keyspace_hits + keyspace_misses) * 100
# Target: 85% or higher
# Slow query log
redis-cli SLOWLOG GET 10
# Check top memory-consuming keys
redis-cli --bigkeys
# Collect monitoring metrics in Node.js
async function getRedisMetrics() {
const info = await redis.info();
const stats: Record<string, string> = {};
info.split('\r\n').forEach(line => {
const [key, value] = line.split(':');
if (key && value) stats[key] = value;
});
const hits = parseInt(stats.keyspace_hits || '0');
const misses = parseInt(stats.keyspace_misses || '0');
const hitRate = hits + misses > 0 ? (hits / (hits + misses) * 100).toFixed(2) : '0';
return {
usedMemory: stats.used_memory_human,
hitRate: `${hitRate}%`,
connectedClients: stats.connected_clients,
totalKeys: stats.db0?.split(',')[0]?.split('=')[1] || '0',
evictedKeys: stats.evicted_keys,
uptime: stats.uptime_in_seconds,
};
}
# maxmemory policy settings (production)
# In redis.conf or docker run:
# maxmemory 512mb
# maxmemory-policy allkeys-lru
# Memory optimization tips
# 1. Use short key names (product:123 vs p:123)
# 2. Avoid caching unnecessary fields
# 3. Hash type compresses with ziplist (hash-max-entries 128)
# 4. Minimize keys without expiration (always set TTL)Core Code
Core Redis caching patterns: Cache-Aside (reads) + distributed lock (stampede prevention) + Write-Through (cache update on write) + related cache invalidation. TTL and locks ensure data consistency and stability.
// Redis Caching Core Pattern (Cache-Aside + Stampede Prevention)
import Redis from 'ioredis';
const redis = new Redis();
async function cachedQuery<T>(
key: string, ttl: number, queryFn: () => Promise<T>
): Promise<T> {
// 1. Check cache
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
// 2. Distributed lock for Cache Stampede prevention
const lock = await redis.set(`lock:${key}`, '1', 'PX', 5000, 'NX');
if (!lock) {
await new Promise(r => setTimeout(r, 200));
const retry = await redis.get(key);
if (retry) return JSON.parse(retry);
}
// 3. Query DB and store in cache
try {
const data = await queryFn();
await redis.set(key, JSON.stringify(data), 'EX', ttl);
return data;
} finally {
await redis.del(`lock:${key}`);
}
}
// Cache invalidation (Write-Through)
async function updateAndInvalidate(id: string, data: any) {
const result = await db.update(id, data);
await redis.set(`item:${id}`, JSON.stringify(result), 'EX', 3600);
await redis.del('list:popular', 'list:recent'); // Invalidate related caches
return result;
}Common Mistakes
Setting the same TTL for all data, resulting in low cache efficiency
Set different TTLs based on data change frequency. Use short TTLs (1-5 minutes) for frequently changing data (inventory, prices) and long TTLs (24 hours) for rarely changing data (categories, settings).
Not configuring maxmemory-policy, causing Redis OOM (Out of Memory) server crash
Always configure maxmemory and maxmemory-policy (recommended: allkeys-lru). When the memory limit is reached, old keys are automatically deleted per LRU policy for stable operation. The noeviction policy causes write failures and is unsuitable for caching.
Using the KEYS command in production, blocking the entire Redis instance
The KEYS command iterates over all keys and must never be used in production. Use the SCAN command for pattern searches, and limit the number of keys scanned at once with the COUNT option.