Redis Caching Strategies That Actually Work in Production
Cache-aside, write-through, cache stampede prevention, TTL strategies, and invalidation patterns. The Redis patterns I've used in production Node.js apps with real code examples.
Everyone tells you to "just add Redis" when your API is slow. Nobody tells you what happens six months later when your cache is serving stale data, your invalidation logic is scattered across 40 files, and a deploy causes a cache stampede that takes down your database harder than if you'd never cached at all.
I've been running Redis in production for years. Not as a toy, not in a tutorial — in systems handling real traffic where getting caching wrong means pager alerts at 3 AM. What follows is everything I've learned about doing it right.
Why Cache?#
Let's start with the obvious: databases are slow relative to memory. A PostgreSQL query that takes 15ms is fast by database standards. But if that query runs on every single API request, and you're handling 1,000 requests per second, that's 15,000ms of cumulative database time per second. Your connection pool is exhausted. Your p99 latency is through the roof. Users are staring at spinners.
Redis serves most reads in under 1ms. That same data, cached, turns a 15ms operation into a 0.3ms operation. That's not a micro-optimization. That's the difference between needing 4 database replicas and needing zero.
But caching isn't free. It adds complexity, introduces consistency problems, and creates an entirely new class of failure modes. Before you cache anything, ask yourself:
When caching helps:
- Data is read far more often than it's written (10:1 ratio or higher)
- The underlying query is expensive (joins, aggregations, external API calls)
- Slight staleness is acceptable (product catalog, user profiles, config)
- You have predictable access patterns (same keys hit repeatedly)
When caching hurts:
- Data changes constantly and must be fresh (real-time stock prices, live scores)
- Every request is unique (search queries with many parameters)
- Your dataset is tiny (if the whole thing fits in your app's memory, skip Redis)
- You don't have the operational maturity to monitor and debug cache issues
Phil Karlton famously said there are only two hard things in computer science: cache invalidation and naming things. He was right about both, but cache invalidation is the one that wakes you up at night.
Setting Up ioredis#
Before we dive into patterns, let's establish the connection. I use ioredis everywhere — it's the most mature Redis client for Node.js, with proper TypeScript support, cluster mode, Sentinel support, and Lua scripting.
import Redis from "ioredis";
const redis = new Redis({
host: process.env.REDIS_HOST || "127.0.0.1",
port: Number(process.env.REDIS_PORT) || 6379,
password: process.env.REDIS_PASSWORD || undefined,
db: Number(process.env.REDIS_DB) || 0,
maxRetriesPerRequest: 3,
retryStrategy(times) {
const delay = Math.min(times * 200, 5000);
return delay;
},
lazyConnect: true,
enableReadyCheck: true,
connectTimeout: 10000,
});
redis.on("error", (err) => {
console.error("[Redis] Connection error:", err.message);
});
redis.on("connect", () => {
console.log("[Redis] Connected");
});
export default redis;A few things worth noting. lazyConnect: true means the connection isn't established until you actually run a command, which is useful during testing and initialization. retryStrategy implements exponential backoff capped at 5 seconds — without this, a Redis outage causes your app to spam reconnection attempts. And maxRetriesPerRequest: 3 ensures individual commands fail fast instead of hanging forever.
Cache-Aside Pattern#
This is the pattern you'll use 80% of the time. It's also called "lazy loading" or "look-aside." The flow is simple:
- Application receives a request
- Check Redis for the cached value
- If found (cache hit), return it
- If not found (cache miss), query the database
- Store the result in Redis
- Return the result
Here's a typed implementation:
import redis from "./redis";
interface CacheOptions {
ttl?: number; // seconds
prefix?: string;
}
async function cacheAside<T>(
key: string,
fetcher: () => Promise<T>,
options: CacheOptions = {}
): Promise<T> {
const { ttl = 3600, prefix = "cache" } = options;
const cacheKey = `${prefix}:${key}`;
// Step 1: Try to read from cache
const cached = await redis.get(cacheKey);
if (cached !== null) {
try {
return JSON.parse(cached) as T;
} catch {
// Corrupted cache entry, delete it and fall through
await redis.del(cacheKey);
}
}
// Step 2: Cache miss — fetch from source
const result = await fetcher();
// Step 3: Store in cache (don't await — fire and forget)
redis
.set(cacheKey, JSON.stringify(result), "EX", ttl)
.catch((err) => {
console.error(`[Cache] Failed to set ${cacheKey}:`, err.message);
});
return result;
}Usage looks like this:
interface User {
id: string;
name: string;
email: string;
plan: "free" | "pro" | "enterprise";
}
async function getUser(userId: string): Promise<User | null> {
return cacheAside<User | null>(
`user:${userId}`,
async () => {
const row = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
return row[0] ?? null;
},
{ ttl: 1800 } // 30 minutes
);
}Notice I fire-and-forget the redis.set call. This is intentional. If Redis is down or slow, the request still completes. The cache is an optimization, not a requirement. If writing to cache fails, the next request will just hit the database again. No big deal.
There's a subtle bug in many cache-aside implementations that people miss: caching null values. If a user doesn't exist and you don't cache that fact, every request for that user hits the database. An attacker can exploit this by requesting random user IDs, turning your cache into a no-op. Always cache the negative result too — just with a shorter TTL.
async function getUserSafe(userId: string): Promise<User | null> {
return cacheAside<User | null>(
`user:${userId}`,
async () => {
const row = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
return row[0] ?? null;
},
{
// Shorter TTL for null results to limit memory usage
// but long enough to absorb repeated misses
ttl: row ? 1800 : 300,
}
);
}Actually, let me restructure that to make the dynamic TTL work properly:
async function getUserWithDynamicTTL(userId: string): Promise<User | null> {
const cacheKey = `cache:user:${userId}`;
const cached = await redis.get(cacheKey);
if (cached !== null) {
return JSON.parse(cached) as User | null;
}
const row = await db.query("SELECT * FROM users WHERE id = $1", [userId]);
const user: User | null = row[0] ?? null;
// Cache exists results for 30 min, null results for 5 min
const ttl = user ? 1800 : 300;
await redis.set(cacheKey, JSON.stringify(user), "EX", ttl);
return user;
}Write-Through and Write-Behind#
Cache-aside works great for read-heavy workloads, but it has a consistency problem: if another service or process updates the database directly, your cache is stale until the TTL expires. Enter write-through and write-behind patterns.
Write-Through#
In write-through, every write goes through the cache layer. The cache is updated first, then the database. This guarantees the cache is always consistent with the database (assuming writes always go through your application).
async function updateUser(
userId: string,
updates: Partial<User>
): Promise<User> {
// Step 1: Update the database
const updated = await db.query(
"UPDATE users SET name = COALESCE($2, name), email = COALESCE($3, email) WHERE id = $1 RETURNING *",
[userId, updates.name, updates.email]
);
const user: User = updated[0];
// Step 2: Update the cache immediately
const cacheKey = `cache:user:${userId}`;
await redis.set(cacheKey, JSON.stringify(user), "EX", 1800);
return user;
}The key difference from cache-aside: we write to the cache on every write, not just on reads. This means the cache is always warm for recently updated data.
The trade-off: write latency increases because every write now touches both the database and Redis. If Redis is slow, your writes are slow. In most applications, reads far outnumber writes, so this trade-off is worth it.
Write-Behind (Write-Back)#
Write-behind flips the script: writes go to Redis first, and the database is updated asynchronously. This gives you extremely fast writes at the cost of potential data loss if Redis goes down before the data is persisted.
async function updateUserWriteBehind(
userId: string,
updates: Partial<User>
): Promise<User> {
const cacheKey = `cache:user:${userId}`;
// Read current state
const current = await redis.get(cacheKey);
const user = current ? JSON.parse(current) as User : null;
if (!user) throw new Error("User not in cache");
// Update cache immediately
const updated = { ...user, ...updates };
await redis.set(cacheKey, JSON.stringify(updated), "EX", 1800);
// Queue database write for async processing
await redis.rpush(
"write_behind:users",
JSON.stringify({ userId, updates, timestamp: Date.now() })
);
return updated;
}You'd then have a separate worker draining that queue:
async function processWriteBehindQueue(): Promise<void> {
while (true) {
const item = await redis.blpop("write_behind:users", 5);
if (item) {
const { userId, updates } = JSON.parse(item[1]);
try {
await db.query(
"UPDATE users SET name = COALESCE($2, name), email = COALESCE($3, email) WHERE id = $1",
[userId, updates.name, updates.email]
);
} catch (err) {
// Re-queue on failure with retry count
console.error("[WriteBehind] Failed:", err);
await redis.rpush("write_behind:users:dlq", item[1]);
}
}
}
}I rarely use write-behind in practice. The data loss risk is real — if Redis crashes before the worker processes the queue, those writes are gone. Use this only for data where eventual consistency is genuinely acceptable, like view counts, analytics events, or non-critical user preferences.
TTL Strategy#
Getting TTL right is more nuanced than it looks. A fixed 1-hour TTL on everything is easy to implement and almost always wrong.
Data Volatility Tiers#
I categorize data into three tiers and assign TTLs accordingly:
const TTL = {
// Tier 1: Rarely changes, expensive to compute
// Examples: product catalog, site config, feature flags
STATIC: 86400, // 24 hours
// Tier 2: Changes occasionally, moderate cost
// Examples: user profiles, team settings, permissions
MODERATE: 1800, // 30 minutes
// Tier 3: Changes frequently, cheap to compute but called often
// Examples: feed data, notification counts, session info
VOLATILE: 300, // 5 minutes
// Tier 4: Ephemeral, used for rate limiting and locks
EPHEMERAL: 60, // 1 minute
// Null results: always short-lived
NOT_FOUND: 120, // 2 minutes
} as const;TTL Jitter: Preventing the Thundering Herd#
Here's a scenario that has bitten me: you deploy your app, the cache is empty, and 10,000 requests all cache the same data with a 1-hour TTL. One hour later, all 10,000 keys expire simultaneously. All 10,000 requests hit the database at once. The database chokes. I've seen this take down a production Postgres instance.
The fix is jitter — adding randomness to TTL values:
function ttlWithJitter(baseTtl: number, jitterPercent = 0.1): number {
const jitter = baseTtl * jitterPercent;
const offset = Math.random() * jitter * 2 - jitter;
return Math.max(1, Math.round(baseTtl + offset));
}
// Instead of: redis.set(key, value, "EX", 3600)
// Use: redis.set(key, value, "EX", ttlWithJitter(3600))
// 3600 ± 10% = random value between 3240 and 3960This spreads expiration across a window, so instead of 10,000 keys expiring at the same second, they expire over a 12-minute window. The database sees a gradual increase in traffic, not a cliff.
For critical paths, I go further and use 20% jitter:
const ttl = ttlWithJitter(3600, 0.2); // 2880–4320 secondsSliding Expiry#
For session-like data where the TTL should reset on every access, use GETEX (Redis 6.2+):
async function getWithSlidingExpiry<T>(
key: string,
ttl: number
): Promise<T | null> {
// GETEX atomically gets the value AND resets the TTL
const value = await redis.getex(key, "EX", ttl);
if (value === null) return null;
return JSON.parse(value) as T;
}If you're on an older Redis version, use a pipeline:
async function getWithSlidingExpiryCompat<T>(
key: string,
ttl: number
): Promise<T | null> {
const pipeline = redis.pipeline();
pipeline.get(key);
pipeline.expire(key, ttl);
const results = await pipeline.exec();
if (!results || !results[0] || results[0][1] === null) return null;
return JSON.parse(results[0][1] as string) as T;
}Cache Stampede (Thundering Herd)#
TTL jitter helps with mass expiration, but it doesn't solve the single-key stampede: when a popular key expires and hundreds of concurrent requests all try to regenerate it simultaneously.
Imagine you cache your homepage feed with a 5-minute TTL. It expires. Fifty concurrent requests see the cache miss. All fifty hit the database with the same expensive query. You've effectively DDoS'd yourself.
Solution 1: Mutex Lock#
Only one request regenerates the cache. Everyone else waits.
async function cacheAsideWithMutex<T>(
key: string,
fetcher: () => Promise<T>,
ttl: number = 3600
): Promise<T | null> {
const cacheKey = `cache:${key}`;
const lockKey = `lock:${key}`;
// Try cache first
const cached = await redis.get(cacheKey);
if (cached !== null) {
return JSON.parse(cached) as T;
}
// Try to acquire lock (NX = only if not exists, EX = auto-expire)
const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
if (acquired) {
try {
// We got the lock — fetch and cache
const result = await fetcher();
await redis.set(
cacheKey,
JSON.stringify(result),
"EX",
ttlWithJitter(ttl)
);
return result;
} finally {
// Release lock
await redis.del(lockKey);
}
}
// Another request holds the lock — wait and retry
await sleep(100);
const retried = await redis.get(cacheKey);
if (retried !== null) {
return JSON.parse(retried) as T;
}
// Still no cache — fall through to database
// (this handles the case where the lock holder failed)
return fetcher();
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}There's a subtle race condition in the lock release above. If the lock holder takes longer than 10 seconds (the lock TTL), another request acquires the lock, and then the first request deletes the second request's lock. The proper fix is to use a unique token:
import { randomUUID } from "crypto";
async function acquireLock(
lockKey: string,
ttl: number
): Promise<string | null> {
const token = randomUUID();
const acquired = await redis.set(lockKey, token, "EX", ttl, "NX");
return acquired ? token : null;
}
async function releaseLock(lockKey: string, token: string): Promise<boolean> {
// Lua script ensures atomic check-and-delete
const script = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
const result = await redis.eval(script, 1, lockKey, token);
return result === 1;
}This is essentially a simplified Redlock. For single-instance Redis, it's sufficient. For Redis Cluster or Sentinel setups, look into the full Redlock algorithm — but honestly, for caching stampede prevention, this simple version works fine.
Solution 2: Probabilistic Early Expiration#
This is my favorite approach. Instead of waiting for the key to expire, randomly regenerate it slightly before expiration. The idea comes from a paper by Vattani, Chierichetti, and Lowenstein.
interface CachedValue<T> {
data: T;
cachedAt: number;
ttl: number;
}
async function cacheWithEarlyExpiration<T>(
key: string,
fetcher: () => Promise<T>,
ttl: number = 3600
): Promise<T> {
const cacheKey = `cache:${key}`;
const cached = await redis.get(cacheKey);
if (cached !== null) {
const entry = JSON.parse(cached) as CachedValue<T>;
const age = (Date.now() - entry.cachedAt) / 1000;
const remaining = entry.ttl - age;
// XFetch algorithm: probabilistically regenerate as expiry approaches
// beta * Math.log(Math.random()) produces a negative number
// that grows larger (more negative) as expiry approaches
const beta = 1; // tuning parameter, 1 works well
const shouldRegenerate =
remaining - beta * Math.log(Math.random()) * -1 <= 0;
if (!shouldRegenerate) {
return entry.data;
}
// Fall through to regenerate
console.log(`[Cache] Early regeneration triggered for ${key}`);
}
const data = await fetcher();
const entry: CachedValue<T> = {
data,
cachedAt: Date.now(),
ttl,
};
// Set with extra buffer so Redis doesn't expire before we can regenerate
await redis.set(
cacheKey,
JSON.stringify(entry),
"EX",
Math.round(ttl * 1.1)
);
return data;
}The beauty of this approach: as the key's remaining TTL decreases, the probability of regeneration increases. With 1,000 concurrent requests, maybe one or two will trigger regeneration while the rest continue serving cached data. No locks, no coordination, no waiting.
Solution 3: Stale-While-Revalidate#
Serve the stale value while regenerating in the background. This gives the best latency because no request ever waits for the fetcher.
async function staleWhileRevalidate<T>(
key: string,
fetcher: () => Promise<T>,
options: {
freshTtl: number; // how long the data is "fresh"
staleTtl: number; // how long stale data can be served
}
): Promise<T | null> {
const cacheKey = `cache:${key}`;
const metaKey = `meta:${key}`;
const [cached, meta] = await redis.mget(cacheKey, metaKey);
if (cached !== null) {
const parsedMeta = meta ? JSON.parse(meta) : null;
const isFresh =
parsedMeta && Date.now() - parsedMeta.cachedAt < options.freshTtl * 1000;
if (!isFresh) {
// Data is stale — serve it but trigger background refresh
revalidateInBackground(key, cacheKey, metaKey, fetcher, options);
}
return JSON.parse(cached) as T;
}
// Complete cache miss — must fetch synchronously
return fetchAndCache(key, cacheKey, metaKey, fetcher, options);
}
async function fetchAndCache<T>(
key: string,
cacheKey: string,
metaKey: string,
fetcher: () => Promise<T>,
options: { freshTtl: number; staleTtl: number }
): Promise<T> {
const data = await fetcher();
const totalTtl = options.freshTtl + options.staleTtl;
const pipeline = redis.pipeline();
pipeline.set(cacheKey, JSON.stringify(data), "EX", totalTtl);
pipeline.set(
metaKey,
JSON.stringify({ cachedAt: Date.now() }),
"EX",
totalTtl
);
await pipeline.exec();
return data;
}
function revalidateInBackground<T>(
key: string,
cacheKey: string,
metaKey: string,
fetcher: () => Promise<T>,
options: { freshTtl: number; staleTtl: number }
): void {
// Use a lock to prevent multiple background refreshes
const lockKey = `revalidate_lock:${key}`;
redis
.set(lockKey, "1", "EX", 30, "NX")
.then((acquired) => {
if (!acquired) return;
return fetchAndCache(key, cacheKey, metaKey, fetcher, options)
.finally(() => redis.del(lockKey));
})
.catch((err) => {
console.error(`[SWR] Background revalidation failed for ${key}:`, err);
});
}Usage:
const user = await staleWhileRevalidate<User>("user:123", fetchUserFromDB, {
freshTtl: 300, // 5 minutes fresh
staleTtl: 3600, // serve stale for up to 1 hour while revalidating
});I use this pattern for anything user-facing where latency matters more than absolute freshness. Dashboard data, profile pages, product listings — all perfect candidates.
Cache Invalidation#
Phil Karlton wasn't joking. Invalidation is where caching goes from "easy optimization" to "distributed systems problem."
Simple Key-Based Invalidation#
The easiest case: when you update a user, delete their cache key.
async function updateUserAndInvalidate(
userId: string,
updates: Partial<User>
): Promise<User> {
const user = await db.query(
"UPDATE users SET name = $2 WHERE id = $1 RETURNING *",
[userId, updates.name]
);
// Invalidate the cache
await redis.del(`cache:user:${userId}`);
return user[0];
}This works until the user data appears in other cached results. Maybe it's embedded in a team members list. Maybe it's in a search result. Maybe it's in 14 different cached API responses. Now you need to track which cache keys contain which entities.
Tag-Based Invalidation#
Tag your cache entries with the entities they contain, then invalidate by tag.
async function setWithTags<T>(
key: string,
value: T,
ttl: number,
tags: string[]
): Promise<void> {
const pipeline = redis.pipeline();
// Store the value
pipeline.set(`cache:${key}`, JSON.stringify(value), "EX", ttl);
// Add the key to each tag's set
for (const tag of tags) {
pipeline.sadd(`tag:${tag}`, `cache:${key}`);
pipeline.expire(`tag:${tag}`, ttl + 3600); // Tag sets live longer than values
}
await pipeline.exec();
}
async function invalidateByTag(tag: string): Promise<number> {
const keys = await redis.smembers(`tag:${tag}`);
if (keys.length === 0) return 0;
const pipeline = redis.pipeline();
for (const key of keys) {
pipeline.del(key);
}
pipeline.del(`tag:${tag}`);
await pipeline.exec();
return keys.length;
}Usage:
// When caching team data, tag it with all member IDs
const team = await fetchTeam(teamId);
await setWithTags(
`team:${teamId}`,
team,
1800,
[
`entity:team:${teamId}`,
...team.members.map((m) => `entity:user:${m.id}`),
]
);
// When user 42 updates their profile, invalidate everything that contains them
await invalidateByTag("entity:user:42");Event-Driven Invalidation#
For larger systems, use Redis Pub/Sub to broadcast invalidation events:
// Publisher (in your API service)
async function publishInvalidation(
entityType: string,
entityId: string
): Promise<void> {
await redis.publish(
"cache:invalidate",
JSON.stringify({ entityType, entityId, timestamp: Date.now() })
);
}
// Subscriber (in each app instance)
const subscriber = new Redis(/* same config */);
subscriber.subscribe("cache:invalidate", (err) => {
if (err) console.error("[PubSub] Subscribe error:", err);
});
subscriber.on("message", async (_channel, message) => {
const { entityType, entityId } = JSON.parse(message);
await invalidateByTag(`entity:${entityType}:${entityId}`);
console.log(`[Cache] Invalidated ${entityType}:${entityId}`);
});This is critical in multi-instance deployments. If you have 4 app servers behind a load balancer, an invalidation on server 1 needs to propagate to all servers. Pub/Sub handles this automatically.
Pattern-Based Invalidation (Carefully)#
Sometimes you need to invalidate all keys matching a pattern. Never use KEYS in production. It blocks the Redis server while scanning the entire keyspace. With millions of keys, this can block for seconds — an eternity in Redis terms.
Use SCAN instead:
async function invalidateByPattern(pattern: string): Promise<number> {
let cursor = "0";
let deletedCount = 0;
do {
const [nextCursor, keys] = await redis.scan(
cursor,
"MATCH",
pattern,
"COUNT",
100
);
cursor = nextCursor;
if (keys.length > 0) {
await redis.del(...keys);
deletedCount += keys.length;
}
} while (cursor !== "0");
return deletedCount;
}
// Invalidate all cached data for a specific team
await invalidateByPattern("cache:team:42:*");SCAN iterates incrementally — it never blocks the server. The COUNT hint suggests how many keys to return per iteration (it's a hint, not a guarantee). For large keyspaces, this is the only safe approach.
That said, pattern-based invalidation is a code smell. If you find yourself scanning frequently, redesign your key structure or use tags. SCAN is O(N) over the keyspace and is meant for maintenance operations, not hot paths.
Data Structures Beyond Strings#
Most developers treat Redis as a key-value store for JSON strings. That's like buying a Swiss army knife and only using the bottle opener. Redis has rich data structures, and choosing the right one can eliminate entire categories of complexity.
Hashes for Objects#
Instead of serializing an entire object as JSON, store it as a Redis Hash. This lets you read and update individual fields without deserializing the whole thing.
// Store user as a hash
async function setUserHash(user: User): Promise<void> {
const key = `user:${user.id}`;
await redis.hset(key, {
name: user.name,
email: user.email,
plan: user.plan,
updatedAt: Date.now().toString(),
});
await redis.expire(key, 1800);
}
// Read specific fields
async function getUserPlan(userId: string): Promise<string | null> {
return redis.hget(`user:${userId}`, "plan");
}
// Update a single field
async function upgradeUserPlan(
userId: string,
plan: string
): Promise<void> {
await redis.hset(`user:${userId}`, "plan", plan);
}
// Read entire hash as object
async function getUserHash(userId: string): Promise<User | null> {
const data = await redis.hgetall(`user:${userId}`);
if (!data || Object.keys(data).length === 0) return null;
return {
id: userId,
name: data.name,
email: data.email,
plan: data.plan as User["plan"],
};
}Hashes are memory-efficient for small objects (Redis uses a compact ziplist encoding under the hood) and avoid the serialize/deserialize overhead. The trade-off: you lose the ability to store nested objects without flattening them first.
Sorted Sets for Leaderboards and Rate Limiting#
Sorted Sets are Redis's most underappreciated data structure. Every member has a score, and the set is always sorted by score. This makes them perfect for leaderboards, ranking, and sliding window rate limiting.
// Leaderboard
async function addScore(
leaderboard: string,
userId: string,
score: number
): Promise<void> {
await redis.zadd(leaderboard, score, userId);
}
async function getTopPlayers(
leaderboard: string,
count: number = 10
): Promise<Array<{ userId: string; score: number }>> {
const results = await redis.zrevrange(
leaderboard,
0,
count - 1,
"WITHSCORES"
);
const players: Array<{ userId: string; score: number }> = [];
for (let i = 0; i < results.length; i += 2) {
players.push({
userId: results[i],
score: parseFloat(results[i + 1]),
});
}
return players;
}
async function getUserRank(
leaderboard: string,
userId: string
): Promise<number | null> {
const rank = await redis.zrevrank(leaderboard, userId);
return rank !== null ? rank + 1 : null; // 0-indexed to 1-indexed
}For sliding window rate limiting:
async function slidingWindowRateLimit(
identifier: string,
windowMs: number,
maxRequests: number
): Promise<{ allowed: boolean; remaining: number }> {
const key = `ratelimit:${identifier}`;
const now = Date.now();
const windowStart = now - windowMs;
const pipeline = redis.pipeline();
// Remove entries outside the window
pipeline.zremrangebyscore(key, 0, windowStart);
// Add current request
pipeline.zadd(key, now, `${now}:${Math.random()}`);
// Count requests in window
pipeline.zcard(key);
// Set expiry on the whole key
pipeline.expire(key, Math.ceil(windowMs / 1000));
const results = await pipeline.exec();
const count = results?.[2]?.[1] as number;
return {
allowed: count <= maxRequests,
remaining: Math.max(0, maxRequests - count),
};
}This is more accurate than the fixed-window counter approach and doesn't suffer from the boundary problem where a burst at the end of one window and the start of the next effectively doubles your rate limit.
Lists for Queues#
Redis Lists with LPUSH/BRPOP make excellent lightweight job queues:
interface Job {
id: string;
type: string;
payload: Record<string, unknown>;
createdAt: number;
}
// Producer
async function enqueueJob(
queue: string,
type: string,
payload: Record<string, unknown>
): Promise<string> {
const job: Job = {
id: randomUUID(),
type,
payload,
createdAt: Date.now(),
};
await redis.lpush(`queue:${queue}`, JSON.stringify(job));
return job.id;
}
// Consumer (blocks until a job is available)
async function dequeueJob(
queue: string,
timeout: number = 5
): Promise<Job | null> {
const result = await redis.brpop(`queue:${queue}`, timeout);
if (!result) return null;
return JSON.parse(result[1]) as Job;
}For anything more complex than basic queuing (retries, dead letter queues, priority, delayed jobs), use BullMQ which builds on Redis but handles all the edge cases.
Sets for Unique Tracking#
Need to track unique visitors, deduplicate events, or check membership? Sets are O(1) for add, remove, and membership checks.
// Track unique visitors per day
async function trackVisitor(
page: string,
visitorId: string
): Promise<boolean> {
const key = `visitors:${page}:${new Date().toISOString().split("T")[0]}`;
const isNew = await redis.sadd(key, visitorId);
// Auto-expire after 48 hours
await redis.expire(key, 172800);
return isNew === 1; // 1 = new member, 0 = already existed
}
// Get unique visitor count
async function getUniqueVisitors(page: string, date: string): Promise<number> {
return redis.scard(`visitors:${page}:${date}`);
}
// Check if user has already performed an action
async function hasUserVoted(pollId: string, userId: string): Promise<boolean> {
return (await redis.sismember(`votes:${pollId}`, userId)) === 1;
}For very large sets (millions of members), consider HyperLogLog instead. It uses only 12KB of memory regardless of cardinality, at the cost of ~0.81% standard error:
// HyperLogLog for approximate unique counts
async function trackVisitorApprox(
page: string,
visitorId: string
): Promise<void> {
const key = `hll:visitors:${page}:${new Date().toISOString().split("T")[0]}`;
await redis.pfadd(key, visitorId);
await redis.expire(key, 172800);
}
async function getApproxUniqueVisitors(
page: string,
date: string
): Promise<number> {
return redis.pfcount(`hll:visitors:${page}:${date}`);
}Serialization: JSON vs MessagePack#
JSON is the default choice for Redis serialization. It's readable, universal, and good enough for most cases. But for high-throughput systems, the serialize/deserialize overhead adds up.
The Problem with JSON#
const user = {
id: "usr_abc123",
name: "Ahmet Kousa",
email: "ahmet@example.com",
plan: "pro",
preferences: {
theme: "dark",
language: "tr",
notifications: true,
},
};
// JSON: 189 bytes
const jsonStr = JSON.stringify(user);
console.log(Buffer.byteLength(jsonStr)); // 189
// JSON.parse on a hot path: ~0.02ms per call
// At 10,000 requests/sec: 200ms total CPU time per secondMessagePack Alternative#
MessagePack is a binary serialization format that's smaller and faster than JSON:
npm install msgpackrimport { pack, unpack } from "msgpackr";
// MessagePack: ~140 bytes (25% smaller)
const packed = pack(user);
console.log(packed.length); // ~140
// Store as Buffer
await redis.set("user:123", packed);
// Read as Buffer
const raw = await redis.getBuffer("user:123");
if (raw) {
const data = unpack(raw);
}Note the use of getBuffer instead of get — this is critical. get returns a string and would corrupt binary data.
Compression for Large Values#
For large cached values (API responses with hundreds of items, rendered HTML), add compression:
import { promisify } from "util";
import { gzip, gunzip } from "zlib";
const gzipAsync = promisify(gzip);
const gunzipAsync = promisify(gunzip);
async function setCompressed<T>(
key: string,
value: T,
ttl: number
): Promise<void> {
const json = JSON.stringify(value);
// Only compress if larger than 1KB (compression overhead isn't worth it for small values)
if (Buffer.byteLength(json) > 1024) {
const compressed = await gzipAsync(json);
await redis.set(key, compressed, "EX", ttl);
} else {
await redis.set(key, json, "EX", ttl);
}
}
async function getCompressed<T>(key: string): Promise<T | null> {
const raw = await redis.getBuffer(key);
if (!raw) return null;
try {
// Try to decompress first
const decompressed = await gunzipAsync(raw);
return JSON.parse(decompressed.toString()) as T;
} catch {
// Not compressed, parse as regular JSON
return JSON.parse(raw.toString()) as T;
}
}In my testing, gzip compression typically reduces JSON payload size by 70-85%. A 50KB API response becomes 8KB. This matters when you're paying for Redis memory — less memory per key means more keys in the same instance.
The trade-off: compression adds 1-3ms of CPU time per operation. For most applications, this is negligible. For ultra-low-latency paths, skip it.
My Recommendation#
Use JSON unless profiling shows it's a bottleneck. The readability and debuggability of JSON in Redis (you can redis-cli GET key and actually read the value) outweighs the performance gain of MessagePack for 95% of applications. Add compression only for values larger than 1KB.
Redis in Next.js#
Next.js has its own caching story (Data Cache, Full Route Cache, etc.), but Redis fills gaps that the built-in caching can't handle — especially when you need to share cache across multiple instances or persist cache across deployments.
Caching API Route Responses#
// app/api/products/route.ts
import { NextResponse } from "next/server";
import redis from "@/lib/redis";
export async function GET(request: Request) {
const url = new URL(request.url);
const category = url.searchParams.get("category") || "all";
const cacheKey = `api:products:${category}`;
// Check cache
const cached = await redis.get(cacheKey);
if (cached) {
return NextResponse.json(JSON.parse(cached), {
headers: {
"X-Cache": "HIT",
"Cache-Control": "public, s-maxage=60",
},
});
}
// Fetch from database
const products = await db.products.findMany({
where: category !== "all" ? { category } : undefined,
orderBy: { createdAt: "desc" },
take: 50,
});
// Cache for 5 minutes with jitter
await redis.set(
cacheKey,
JSON.stringify(products),
"EX",
ttlWithJitter(300)
);
return NextResponse.json(products, {
headers: {
"X-Cache": "MISS",
"Cache-Control": "public, s-maxage=60",
},
});
}The X-Cache header is invaluable for debugging. When latency spikes, a quick curl -I tells you whether the cache is working.
Session Storage#
Next.js with Redis for sessions beats JWT for stateful applications:
// lib/session.ts
import { randomUUID } from "crypto";
import redis from "./redis";
interface Session {
userId: string;
role: string;
createdAt: number;
data: Record<string, unknown>;
}
const SESSION_TTL = 86400; // 24 hours
const SESSION_PREFIX = "session:";
export async function createSession(
userId: string,
role: string
): Promise<string> {
const sessionId = randomUUID();
const session: Session = {
userId,
role,
createdAt: Date.now(),
data: {},
};
await redis.set(
`${SESSION_PREFIX}${sessionId}`,
JSON.stringify(session),
"EX",
SESSION_TTL
);
return sessionId;
}
export async function getSession(
sessionId: string
): Promise<Session | null> {
const key = `${SESSION_PREFIX}${sessionId}`;
// Use GETEX to refresh TTL on every access (sliding expiry)
const raw = await redis.getex(key, "EX", SESSION_TTL);
if (!raw) return null;
return JSON.parse(raw) as Session;
}
export async function destroySession(sessionId: string): Promise<void> {
await redis.del(`${SESSION_PREFIX}${sessionId}`);
}
// Destroy all sessions for a user (useful for "logout everywhere")
export async function destroyAllUserSessions(
userId: string
): Promise<void> {
// This requires maintaining a user->sessions index
const sessionIds = await redis.smembers(`user_sessions:${userId}`);
if (sessionIds.length > 0) {
const pipeline = redis.pipeline();
for (const sid of sessionIds) {
pipeline.del(`${SESSION_PREFIX}${sid}`);
}
pipeline.del(`user_sessions:${userId}`);
await pipeline.exec();
}
}Rate Limiting Middleware#
// middleware.ts (or a helper used by middleware)
import redis from "@/lib/redis";
interface RateLimitResult {
allowed: boolean;
remaining: number;
resetAt: number;
}
export async function rateLimit(
identifier: string,
limit: number = 60,
windowSeconds: number = 60
): Promise<RateLimitResult> {
const key = `rate:${identifier}`;
const now = Math.floor(Date.now() / 1000);
const windowStart = now - windowSeconds;
// Lua script for atomic rate limiting
const script = `
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1])
redis.call('ZADD', KEYS[1], ARGV[2], ARGV[3])
local count = redis.call('ZCARD', KEYS[1])
redis.call('EXPIRE', KEYS[1], ARGV[4])
return count
`;
const count = (await redis.eval(
script,
1,
key,
windowStart,
now,
`${now}:${Math.random()}`,
windowSeconds
)) as number;
return {
allowed: count <= limit,
remaining: Math.max(0, limit - count),
resetAt: now + windowSeconds,
};
}The Lua script is important here. Without it, the ZREMRANGEBYSCORE + ZADD + ZCARD sequence isn't atomic, and under high concurrency, the count could be inaccurate. Lua scripts execute atomically in Redis — they can't be interleaved with other commands.
Distributed Locks for Next.js#
When you have multiple Next.js instances and need to ensure only one processes a task (like sending a scheduled email or running a cleanup job):
// lib/distributed-lock.ts
import { randomUUID } from "crypto";
import redis from "./redis";
export async function withLock<T>(
lockName: string,
fn: () => Promise<T>,
options: { ttl?: number; retryDelay?: number; maxRetries?: number } = {}
): Promise<T | null> {
const { ttl = 30, retryDelay = 200, maxRetries = 10 } = options;
const token = randomUUID();
const lockKey = `dlock:${lockName}`;
// Try to acquire lock
for (let attempt = 0; attempt < maxRetries; attempt++) {
const acquired = await redis.set(lockKey, token, "EX", ttl, "NX");
if (acquired) {
try {
// Extend lock automatically for long-running tasks
const extender = setInterval(async () => {
const script = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("expire", KEYS[1], ARGV[2])
else
return 0
end
`;
await redis.eval(script, 1, lockKey, token, ttl);
}, (ttl * 1000) / 3);
const result = await fn();
clearInterval(extender);
return result;
} finally {
// Release lock only if we still own it
const releaseScript = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
await redis.eval(releaseScript, 1, lockKey, token);
}
}
// Wait before retrying
await new Promise((r) => setTimeout(r, retryDelay));
}
// Could not acquire lock after all retries
return null;
}Usage:
// In a cron-triggered API route
export async function POST() {
const result = await withLock("daily-report", async () => {
// Only one instance runs this
const report = await generateDailyReport();
await sendReportEmail(report);
return report;
});
if (result === null) {
return NextResponse.json(
{ message: "Another instance is already processing" },
{ status: 409 }
);
}
return NextResponse.json({ success: true });
}The lock extension interval at ttl/3 is important. Without it, if your task takes longer than the lock TTL, the lock expires and another instance grabs it. The extender keeps the lock alive as long as the task is running.
Monitoring and Debugging#
Redis is fast until it isn't. When problems hit, you need visibility.
Cache Hit Ratio#
The single most important metric. Track it in your application:
// lib/cache-metrics.ts
import redis from "./redis";
const METRICS_KEY = "metrics:cache";
export async function recordCacheHit(): Promise<void> {
await redis.hincrby(METRICS_KEY, "hits", 1);
}
export async function recordCacheMiss(): Promise<void> {
await redis.hincrby(METRICS_KEY, "misses", 1);
}
export async function getCacheStats(): Promise<{
hits: number;
misses: number;
hitRate: number;
}> {
const stats = await redis.hgetall(METRICS_KEY);
const hits = parseInt(stats.hits || "0", 10);
const misses = parseInt(stats.misses || "0", 10);
const total = hits + misses;
return {
hits,
misses,
hitRate: total > 0 ? hits / total : 0,
};
}
// Reset metrics daily
export async function resetCacheStats(): Promise<void> {
await redis.del(METRICS_KEY);
}A healthy cache hit ratio is above 90%. If you're below 80%, either your TTLs are too short, your cache keys are too specific, or your access patterns are more random than you thought.
INFO Command#
The INFO command is Redis's built-in health dashboard:
redis-cli INFO memory# Memory
used_memory:1234567
used_memory_human:1.18M
used_memory_peak:2345678
used_memory_peak_human:2.24M
maxmemory:0
maxmemory_policy:noeviction
mem_fragmentation_ratio:1.23
Key metrics to monitor:
- used_memory vs maxmemory: Are you approaching the limit?
- mem_fragmentation_ratio: Above 1.5 means Redis is using significantly more RSS than logical memory. Consider a restart.
- evicted_keys: If this is non-zero and you didn't intend eviction, you're out of memory.
redis-cli INFO statsWatch for:
- keyspace_hits / keyspace_misses: Server-level hit rate
- total_commands_processed: Throughput
- instantaneous_ops_per_sec: Current throughput
MONITOR (Use With Extreme Caution)#
MONITOR streams every command executed on the Redis server in real-time. It's incredibly useful for debugging and incredibly dangerous in production.
# NEVER leave this running in production
# It adds significant overhead and can log sensitive data
redis-cli MONITOR1614556800.123456 [0 127.0.0.1:52340] "SET" "cache:user:123" "{\"name\":\"Ahmet\"}" "EX" "1800"
1614556800.234567 [0 127.0.0.1:52340] "GET" "cache:user:456"
I use MONITOR for exactly two things: debugging key naming issues during development, and verifying that a specific code path is hitting Redis as expected. Never for more than 30 seconds. Never in production unless you've already exhausted other debugging options.
Keyspace Notifications#
Want to know when keys expire or get deleted? Redis can publish events:
# Enable keyspace notifications for expired and evicted events
redis-cli CONFIG SET notify-keyspace-events Exconst subscriber = new Redis(/* config */);
// Listen for key expiration events
subscriber.subscribe("__keyevent@0__:expired", (err) => {
if (err) console.error("Subscribe error:", err);
});
subscriber.on("message", (_channel, expiredKey) => {
console.log(`Key expired: ${expiredKey}`);
// Proactively regenerate important keys
if (expiredKey.startsWith("cache:homepage")) {
regenerateHomepageCache().catch(console.error);
}
});This is useful for proactive cache warming — instead of waiting for a user to trigger a cache miss, you regenerate critical entries the moment they expire.
Memory Analysis#
When Redis memory grows unexpectedly, you need to find which keys are consuming the most:
# Sample 10 largest keys
redis-cli --bigkeys# Scanning the entire keyspace to find biggest keys
[00.00%] Biggest string found so far '"cache:search:electronics"' with 524288 bytes
[25.00%] Biggest zset found so far '"leaderboard:global"' with 150000 members
[50.00%] Biggest hash found so far '"session:abc123"' with 45 fields
For more detailed analysis:
# Memory usage of a specific key (in bytes)
redis-cli MEMORY USAGE "cache:search:electronics"// Programmatic memory analysis
async function analyzeMemory(pattern: string): Promise<void> {
let cursor = "0";
const stats: Array<{ key: string; bytes: number }> = [];
do {
const [nextCursor, keys] = await redis.scan(
cursor,
"MATCH",
pattern,
"COUNT",
100
);
cursor = nextCursor;
for (const key of keys) {
const bytes = await redis.memory("USAGE", key);
if (bytes) {
stats.push({ key, bytes: bytes as number });
}
}
} while (cursor !== "0");
// Sort by size descending
stats.sort((a, b) => b.bytes - a.bytes);
console.log("Top 20 keys by memory usage:");
for (const { key, bytes } of stats.slice(0, 20)) {
const mb = (bytes / 1024 / 1024).toFixed(2);
console.log(` ${key}: ${mb} MB`);
}
}Eviction Policies#
If your Redis instance has a maxmemory limit (it should), configure an eviction policy:
# In redis.conf or via CONFIG SET
maxmemory 512mb
maxmemory-policy allkeys-lruAvailable policies:
- noeviction: Returns error when memory is full (default, worst for caching)
- allkeys-lru: Evict least recently used key (best general-purpose choice for caching)
- allkeys-lfu: Evict least frequently used key (better if some keys are accessed in bursts)
- volatile-lru: Only evict keys with TTL set (useful if you mix cache and persistent data)
- allkeys-random: Random eviction (surprisingly decent, no overhead)
For pure caching workloads, allkeys-lfu is usually the best choice. It keeps frequently accessed keys in memory even if they haven't been accessed recently.
Putting It All Together: A Production Cache Module#
Here's the complete cache module I use in production, combining everything we've discussed:
// lib/cache.ts
import Redis from "ioredis";
const redis = new Redis({
host: process.env.REDIS_HOST || "127.0.0.1",
port: Number(process.env.REDIS_PORT) || 6379,
password: process.env.REDIS_PASSWORD || undefined,
maxRetriesPerRequest: 3,
retryStrategy(times) {
return Math.min(times * 200, 5000);
},
});
// TTL tiers
const TTL = {
STATIC: 86400,
MODERATE: 1800,
VOLATILE: 300,
EPHEMERAL: 60,
NOT_FOUND: 120,
} as const;
type TTLTier = keyof typeof TTL;
function ttlWithJitter(base: number, jitter = 0.1): number {
const offset = base * jitter * (Math.random() * 2 - 1);
return Math.max(1, Math.round(base + offset));
}
// Core cache-aside with stampede protection
async function get<T>(
key: string,
fetcher: () => Promise<T>,
options: {
tier?: TTLTier;
ttl?: number;
tags?: string[];
swr?: { freshTtl: number; staleTtl: number };
} = {}
): Promise<T> {
const { tier = "MODERATE", tags } = options;
const baseTtl = options.ttl ?? TTL[tier];
const cacheKey = `c:${key}`;
// Check cache
const cached = await redis.get(cacheKey);
if (cached !== null) {
try {
const parsed = JSON.parse(cached);
recordHit();
return parsed as T;
} catch {
await redis.del(cacheKey);
}
}
recordMiss();
// Acquire lock to prevent stampede
const lockKey = `lock:${key}`;
const acquired = await redis.set(lockKey, "1", "EX", 10, "NX");
if (!acquired) {
// Another process is fetching — wait briefly and retry cache
await new Promise((r) => setTimeout(r, 150));
const retried = await redis.get(cacheKey);
if (retried) return JSON.parse(retried) as T;
}
try {
const result = await fetcher();
const ttl = ttlWithJitter(baseTtl);
const pipeline = redis.pipeline();
pipeline.set(cacheKey, JSON.stringify(result), "EX", ttl);
// Store tag associations
if (tags) {
for (const tag of tags) {
pipeline.sadd(`tag:${tag}`, cacheKey);
pipeline.expire(`tag:${tag}`, ttl + 3600);
}
}
await pipeline.exec();
return result;
} finally {
await redis.del(lockKey);
}
}
// Invalidation
async function invalidate(...keys: string[]): Promise<void> {
if (keys.length === 0) return;
await redis.del(...keys.map((k) => `c:${k}`));
}
async function invalidateByTag(tag: string): Promise<number> {
const keys = await redis.smembers(`tag:${tag}`);
if (keys.length === 0) return 0;
const pipeline = redis.pipeline();
for (const key of keys) {
pipeline.del(key);
}
pipeline.del(`tag:${tag}`);
await pipeline.exec();
return keys.length;
}
// Metrics
function recordHit(): void {
redis.hincrby("metrics:cache", "hits", 1).catch(() => {});
}
function recordMiss(): void {
redis.hincrby("metrics:cache", "misses", 1).catch(() => {});
}
async function stats(): Promise<{
hits: number;
misses: number;
hitRate: string;
}> {
const raw = await redis.hgetall("metrics:cache");
const hits = parseInt(raw.hits || "0", 10);
const misses = parseInt(raw.misses || "0", 10);
const total = hits + misses;
return {
hits,
misses,
hitRate: total > 0 ? ((hits / total) * 100).toFixed(1) + "%" : "N/A",
};
}
export const cache = {
get,
invalidate,
invalidateByTag,
stats,
redis,
TTL,
};Usage across the application:
import { cache } from "@/lib/cache";
// Simple cache-aside
const products = await cache.get("products:featured", fetchFeaturedProducts, {
tier: "VOLATILE",
tags: ["entity:products"],
});
// With custom TTL
const config = await cache.get("app:config", fetchAppConfig, {
ttl: 43200, // 12 hours
});
// After updating a product
await cache.invalidateByTag("entity:products");
// Check health
const metrics = await cache.stats();
console.log(`Cache hit rate: ${metrics.hitRate}`);Common Mistakes I've Made (So You Don't Have To)#
1. Not setting maxmemory. Redis will happily use all available memory until the OS kills it. Always set a limit.
2. Using KEYS in production. It blocks the server. Use SCAN. I learned this when a KEYS * call from a monitoring script caused 3 seconds of downtime.
3. Caching too aggressively. Not everything needs to be cached. If your database query takes 2ms and is called 10 times per minute, caching adds complexity for negligible benefit.
4. Ignoring serialization costs. I once cached a 2MB JSON blob and was puzzled why cache reads were slow. The serialization overhead was larger than the database query it was supposed to save.
5. No graceful degradation. When Redis goes down, your app should still work — just slower. Wrap every cache call in a try/catch that falls back to the database. Never let a cache failure become a user-facing error.
async function resilientGet<T>(
key: string,
fetcher: () => Promise<T>
): Promise<T> {
try {
return await cache.get(key, fetcher);
} catch (err) {
console.error(`[Cache] Degraded mode for ${key}:`, err);
return fetcher(); // Bypass cache entirely
}
}6. Not monitoring evictions. If Redis is evicting keys, you're either under-provisioned or caching too much. Either way, you need to know.
7. Sharing a Redis instance between caching and persistent data. Use separate instances (or at least separate databases). A cache eviction policy that deletes your job queue entries is a bad day for everyone.
Wrapping Up#
Redis caching isn't hard, but it's easy to get wrong. Start with cache-aside, add TTL jitter from day one, monitor your hit rate, and resist the urge to cache everything.
The best caching strategy is the one you can reason about at 3 AM when something breaks. Keep it simple, keep it observable, and remember that every cached value is a lie you told your users about the state of your data — your job is to keep that lie as small and short-lived as possible.