Why Cache? And Why Redis?
Database queries are typically the slowest part of a web request — a single page might trigger 20-50 queries. At low traffic this is fine. At scale, it's catastrophic.
Redis is an in-memory data structure store that can serve millions of requests per second with sub-millisecond latency. It's not just a cache: it's also a message broker, session store, rate limiter, and real-time leaderboard. But this guide focuses on what it's most commonly used for: caching.
Core Redis Data Types for Caching
# String — most common, stores any value as a string
SET user:123 '{"id":123,"name":"Alice","email":"alice@example.com"}'
GET user:123
SETEX user:123 3600 '...' # Set with 60-minute expiry
# Hash — ideal for objects (avoid serialization overhead)
HSET user:123 name "Alice" email "alice@example.com" age 30
HGET user:123 name # Get single field
HGETALL user:123 # Get all fields
HINCRBY user:123 loginCount 1 # Increment a field
# List — ordered collections, queues
RPUSH queue:emails "email1" "email2"
LPOP queue:emails # Dequeue
LRANGE queue:emails 0 -1 # Get all
# Set — unique members, tags
SADD user:123:tags "premium" "beta-tester"
SMEMBERS user:123:tags
SISMEMBER user:123:tags "premium" # O(1) membership check
# Sorted Set — ranking/leaderboards
ZADD leaderboard 1500 "player:alice"
ZADD leaderboard 2300 "player:bob"
ZRANGE leaderboard 0 -1 REV WITHSCORES # Top players
ZRANK leaderboard "player:alice" # Alice's rank
Setting Up Redis with Node.js
import { createClient } from 'redis';
const redis = createClient({
url: process.env.REDIS_URL ?? 'redis://localhost:6379',
socket: {
reconnectStrategy: (retries) => Math.min(retries * 50, 2000),
connectTimeout: 5000,
},
});
redis.on('error', (err) => console.error('Redis error:', err));
redis.on('connect', () => console.log('Redis connected'));
redis.on('reconnecting', () => console.log('Redis reconnecting...'));
await redis.connect();
// Helper: get or set with automatic JSON serialization
async function getOrSet(key, ttlSeconds, fetchFn) {
const cached = await redis.get(key);
if (cached !== null) {
return JSON.parse(cached);
}
const value = await fetchFn();
await redis.setEx(key, ttlSeconds, JSON.stringify(value));
return value;
}
// Usage
const user = await getOrSet(
`user:${userId}`,
3600, // 1 hour TTL
() => db.users.findById(userId)
);
The Four Caching Patterns
1. Cache-Aside (Lazy Loading) — Most Common
Application code manages the cache:
async function getUser(userId) {
// 1. Check cache first
const cacheKey = `user:${userId}`;
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached); // Cache hit
}
// 2. Cache miss: fetch from database
const user = await db.users.findById(userId);
if (!user) return null;
// 3. Store in cache for next time
await redis.setEx(cacheKey, 3600, JSON.stringify(user));
return user;
}
// On update: invalidate the cache
async function updateUser(userId, data) {
await db.users.update(userId, data);
await redis.del(`user:${userId}`); // Force re-fetch next time
}
Pros: Only caches what's actually requested. Cache failures don't break the app. Cons: First request is slow (cache miss). Potential for stale data.
2. Write-Through
Write to cache and database simultaneously:
async function updateUserWriteThrough(userId, data) {
// Update both simultaneously
const [updatedUser] = await Promise.all([
db.users.update(userId, data),
redis.setEx(`user:${userId}`, 3600, JSON.stringify({ ...data, id: userId }))
]);
return updatedUser;
}
Pros: Cache is always fresh. Reads are always fast. Cons: Write latency increases. Warms cache for data that might never be read.
3. Write-Behind (Write-Back)
Write to cache immediately, asynchronously persist to database:
// Write to cache immediately, queue DB write
async function updateUserWriteBehind(userId, data) {
const key = `user:${userId}`;
// Instant write to cache
await redis.setEx(key, 3600, JSON.stringify(data));
// Queue background DB write
await redis.lPush('write-queue', JSON.stringify({
type: 'update_user',
userId,
data
}));
return data;
}
// Background worker drains the queue
async function processWriteQueue() {
while (true) {
const item = await redis.brPop('write-queue', 0); // Blocking pop
const { type, userId, data } = JSON.parse(item.element);
if (type === 'update_user') {
await db.users.update(userId, data);
}
}
}
Pros: Extremely fast writes. Handles write bursts. Cons: Risk of data loss if Redis crashes before DB write. Complex implementation.
4. Read-Through
Cache sits in front of the database, transparent to the application:
// In a read-through setup, the cache layer itself fetches from DB on miss
// Often implemented as a middleware or proxy layer
class ReadThroughCache {
constructor(redis, db) {
this.redis = redis;
this.db = db;
}
async get(key, fetchFn, ttl = 3600) {
const cached = await this.redis.get(key);
if (cached !== null) return JSON.parse(cached);
// Cache itself fetches from the source
const value = await fetchFn();
if (value !== null) {
await this.redis.setEx(key, ttl, JSON.stringify(value));
}
return value;
}
}
const cache = new ReadThroughCache(redis, db);
const user = await cache.get(`user:${id}`, () => db.users.findById(id));
Cache Invalidation Strategies
Cache invalidation is notoriously hard. Here are the main approaches:
// 1. TTL-based (simplest) — let cache expire naturally
await redis.setEx('product:123', 300, JSON.stringify(product)); // 5 min TTL
// 2. Event-driven invalidation — delete on update
async function updateProduct(productId, data) {
await db.products.update(productId, data);
// Invalidate related cache keys
await redis.del(`product:${productId}`);
await redis.del(`category:${data.categoryId}:products`); // Also invalidate category listing
}
// 3. Cache versioning — change key prefix on schema change
const CACHE_VERSION = 'v2';
const key = `${CACHE_VERSION}:user:${userId}`;
// 4. Tag-based invalidation using Redis sets
async function setWithTags(key, value, ttl, tags) {
const pipeline = redis.multi();
pipeline.setEx(key, ttl, JSON.stringify(value));
// Track which keys have each tag
for (const tag of tags) {
pipeline.sAdd(`tag:${tag}`, key);
pipeline.expire(`tag:${tag}`, ttl + 60);
}
await pipeline.exec();
}
async function invalidateByTag(tag) {
const keys = await redis.sMembers(`tag:${tag}`);
if (keys.length > 0) {
await redis.del([...keys, `tag:${tag}`]);
}
}
// Usage: invalidate all "user-profile" tagged keys at once
await invalidateByTag('user-profile');
Avoiding Cache Stampede
When cache expires, many requests simultaneously hit the database:
// Problem: Many requests hit the DB simultaneously when cache expires
// Solution 1: Mutex lock
import { Mutex } from 'async-mutex';
const locks = new Map();
async function getWithLock(key, ttl, fetchFn) {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
// Only one fetcher at a time
if (!locks.has(key)) locks.set(key, new Mutex());
return locks.get(key).runExclusive(async () => {
// Double-check after acquiring lock
const cached2 = await redis.get(key);
if (cached2) return JSON.parse(cached2);
const value = await fetchFn();
await redis.setEx(key, ttl, JSON.stringify(value));
return value;
});
}
// Solution 2: Probabilistic early expiry
async function getWithEarlyExpiry(key, ttl, fetchFn) {
const data = await redis.get(key);
if (data) {
const { value, expiry } = JSON.parse(data);
const now = Date.now() / 1000;
const timeLeft = expiry - now;
// Randomly recompute before expiry (avoids synchronized stampede)
if (timeLeft > 0 && Math.random() > timeLeft / (ttl * 0.1)) {
return value; // Use cached value
}
}
const value = await fetchFn();
await redis.setEx(key, ttl, JSON.stringify({
value,
expiry: Date.now() / 1000 + ttl
}));
return value;
}
Redis for Session Storage
// express-session with Redis
import session from 'express-session';
import { RedisStore } from 'connect-redis';
app.use(session({
store: new RedisStore({ client: redis }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 7 * 24 * 60 * 60 * 1000, // 1 week
sameSite: 'lax',
},
name: '__session', // Don't expose 'connect.sid'
}));
Redis for Rate Limiting
// Sliding window rate limiter
async function checkRateLimit(identifier, limit, windowSeconds) {
const key = `ratelimit:${identifier}`;
const now = Date.now();
const windowStart = now - windowSeconds * 1000;
const pipeline = redis.multi();
pipeline.zRemRangeByScore(key, '-inf', windowStart); // Remove old entries
pipeline.zAdd(key, { score: now, value: now.toString() });
pipeline.zCard(key); // Count requests
pipeline.expire(key, windowSeconds);
const results = await pipeline.exec();
const requestCount = results[2];
return {
allowed: requestCount <= limit,
count: requestCount,
remaining: Math.max(0, limit - requestCount),
};
}
// Usage in Express middleware
app.use(async (req, res, next) => {
const identifier = req.ip;
const { allowed, remaining } = await checkRateLimit(identifier, 100, 60);
res.set('X-RateLimit-Remaining', remaining.toString());
if (!allowed) {
return res.status(429).json({ error: 'Rate limit exceeded' });
}
next();
});
Redis Pub/Sub for Real-Time Features
// Publisher
async function publishUserUpdate(userId, event) {
await redis.publish(`user:${userId}:events`, JSON.stringify(event));
}
// Subscriber (separate connection — subscribers can only subscribe)
const subscriber = redis.duplicate();
await subscriber.connect();
await subscriber.subscribe('user:*:events', (message, channel) => {
const event = JSON.parse(message);
const userId = channel.split(':')[1];
console.log(`User ${userId} event:'`, event);
// Broadcast to WebSocket clients
wsServer.to(`user-${userId}`).emit('update', event);
});
Key Naming Conventions
# ✅ Good key naming — hierarchical, descriptive
user:123 # User object
user:123:sessions # User's sessions
user:123:preferences # User preferences
product:456:details # Product details
product:456:reviews:page:1 # Paginated reviews
search:q:javascript:page:1 # Search results cache
ratelimit:192.168.1.1 # Rate limit tracker
lock:payment:order:789 # Distributed lock
# ❌ Bad key naming — flat, unstructured
user123data
product_456
search_javascript_1
→ View and explore complex JSON data structures with the JSON Viewer.