How to Use Redis for Caching in Node.js
Slow API responses and database bottlenecks kill user retention. Redis caching cuts response times from hundreds of milliseconds to single digits — but most tutorials only show you the happy path. This guide covers real production patterns: cache invalidation, stampede prevention, eviction policies, and distributed invalidation across multiple Node.js instances.
Understanding Redis Architecture and When to Cache

What Redis Is and Why It Outperforms Traditional Caching
Redis is an in-memory key-value store. "In-memory" is the operative phrase — your data lives in RAM, not on disk. That's why a Redis read takes roughly 0.1–1ms versus a PostgreSQL query at 50–200ms or a third-party API call at 300–2000ms. The performance difference isn't marginal; it's an order of magnitude.
The volatility trade-off is real. By default, Redis stores nothing to disk. Restart the process and your cache is gone — which is fine for a cache, but dangerous if you're also using Redis for sessions or queues. Enable RDB snapshots for periodic persistence or AOF (Append-Only File) for near-real-time durability if your use case demands it.
Run this to see raw Redis throughput on your own machine:
# Install redis-tools, then benchmark
redis-benchmark -h 127.0.0.1 -p 6379 -n 100000 -c 50 -q
# Sample output:
# SET: 112359.55 requests per second
# GET: 119047.62 requests per second
# LPUSH: 108695.65 requests per second
Over 100k operations per second on a single node. Your database cannot compete with that for repeated reads of the same data.
Cache-Aside vs. Write-Through vs. Write-Behind Patterns
Most tutorials only show cache-aside. Here are all three, because your architecture will determine which one fits.
Cache-Aside (Lazy Loading) — Check cache first. On a miss, fetch from the database, store in Redis, return the result. The application controls all cache interactions. This is the most common pattern and the right default choice.
Write-Through — Every database write also updates the cache synchronously. Writes are slower, but reads are always cache-warm. Good for read-heavy data that changes frequently enough that stale data is unacceptable.
Write-Behind (Write-Back) — Write to cache immediately and return success. A background worker flushes changes to the database asynchronously. Fastest possible write latency, but you accept the risk of data loss if Redis crashes before the flush completes. Use this only when write speed matters more than durability.
// WRONG — scattered caching logic in your route handler
app.get('/api/products/:id', async (req, res) => {
const cached = await redis.get(`product:${req.params.id}`);
if (cached) return res.json(JSON.parse(cached));
const product = await db.query('SELECT * FROM products WHERE id = $1', [req.params.id]);
await redis.setEx(`product:${req.params.id}`, 3600, JSON.stringify(product.rows[0]));
res.json(product.rows[0]);
});
// RIGHT — pattern abstracted, logic separated (full implementation below)
| Pattern | Read Performance | Write Performance | Consistency | Complexity |
|---|---|---|---|---|
| Cache-Aside | Fast after warm-up | Normal | Eventually consistent | Low |
| Write-Through | Always fast | Slower | Strong | Medium |
| Write-Behind | Always fast | Fastest | Weak (async) | High |
Setting Up Redis Client and Implementing Basic Caching
Installing Redis and Configuring the Node.js Client
For local development, Docker is the fastest path. For production, use a managed service like AWS ElastiCache or Redis Cloud — you don't want to manage Redis persistence, replication, and failover yourself.
# docker-compose.yml
version: '3.8'
services:
redis:
image: redis:7.2-alpine
ports:
- "6379:6379"
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
volumes:
- redis_data:/data
volumes:
redis_data:
npm install redis
The official redis package (v4+) is async/await native. Here's a production-grade client setup with connection error handling and retry logic:
// lib/redisClient.js
import { createClient } from 'redis';
let client;
export async function getRedisClient() {
if (client && client.isReady) return client;
client = createClient({
socket: {
host: process.env.REDIS_HOST || '127.0.0.1',
port: parseInt(process.env.REDIS_PORT || '6379'),
reconnectStrategy: (retries) => {
if (retries > 10) {
console.error('Redis: max reconnect attempts reached');
return new Error('Max reconnect attempts reached');
}
// Exponential backoff: 100ms, 200ms, 400ms...
return Math.min(retries * 100, 3000);
},
},
password: process.env.REDIS_PASSWORD,
});
client.on('error', (err) => console.error('Redis client error:', err));
client.on('connect', () => console.log('Redis: connected'));
client.on('reconnecting', () => console.warn('Redis: reconnecting...'));
await client.connect();
return client;
}
Connection pooling matters under high concurrency. The redis v4 client uses a single connection by default, which serializes commands. For heavy workloads, ioredis has built-in cluster support and connection pooling that makes it a stronger choice for distributed Node.js deployments. See the official node-redis documentation for the full options reference.
Building a Centralized Caching Middleware Layer
Stop writing cache logic inside route handlers. Build one reusable middleware and attach it declaratively to any route. This keeps your business logic clean and makes cache behavior easy to audit.
// middleware/cache.js
import { getRedisClient } from '../lib/redisClient.js';
/**
* Creates an Express caching middleware
* @param {number} ttl - Time to live in seconds
* @param {function} keyGenerator - Function that takes (req) and returns cache key string
*/
export function cacheMiddleware(ttl = 3600, keyGenerator = null) {
return async (req, res, next) => {
const redis = await getRedisClient().catch(() => null);
// Circuit breaker: if Redis is down, skip caching entirely
if (!redis) {
console.warn('Cache: Redis unavailable, bypassing cache');
return next();
}
const cacheKey = keyGenerator
? keyGenerator(req)
: `route:${req.method}:${req.originalUrl}`;
try {
const cached = await redis.get(cacheKey);
if (cached) {
res.setHeader('X-Cache', 'HIT');
res.setHeader('X-Cache-Key', cacheKey);
return res.json(JSON.parse(cached));
}
// Monkey-patch res.json to intercept the response and cache it
const originalJson = res.json.bind(res);
res.json = async (data) => {
res.setHeader('X-Cache', 'MISS');
// Only cache successful responses
if (res.statusCode >= 200 && res.statusCode < 300) {
await redis.setEx(cacheKey, ttl, JSON.stringify(data)).catch((err) => {
console.error('Cache: failed to store key', cacheKey, err.message);
});
}
return originalJson(data);
};
next();
} catch (err) {
console.error('Cache middleware error:', err.message);
next(); // Always fall through to the real handler on error
}
};
}
// routes/users.js — clean route handlers, zero cache logic
import { cacheMiddleware } from '../middleware/cache.js';
import { getUserById, getTopUsers } from '../services/userService.js';
// Cache user profiles for 1 hour, keyed by user ID
router.get(
'/api/users/:id',
cacheMiddleware(3600, (req) => `user:${req.params.id}`),
async (req, res) => {
const user = await getUserById(req.params.id);
if (!user) return res.status(404).json({ error: 'User not found' });
res.json(user);
}
);
// Cache leaderboard for 5 minutes (changes frequently)
router.get(
'/api/users/top',
cacheMiddleware(300, () => 'users:leaderboard'),
async (req, res) => {
const users = await getTopUsers(50);
res.json(users);
}
);
Key naming matters more than most developers realize. Use colon-separated namespaces: user:123, post:456:comments, search:nodejs:page:1. This lets you bulk-delete related keys using pattern matching later. See our Node.js architecture patterns guide for more on organizing service layers.
Cache Invalidation, TTL, and Eviction Strategies
Implementing Time-To-Live (TTL) and Smart Expiration
TTL is your first line of defense against stale data. The mistake is picking a single value for everything. Different data has different freshness requirements.
// config/cacheTTL.js — centralize TTL decisions
export const TTL = {
// Changes every few seconds: live scores, stock prices
REALTIME: 10,
// Changes often: search results, feed data
FREQUENT: 300, // 5 minutes
// Changes sometimes: user profiles, product listings
STANDARD: 3600, // 1 hour
// Rarely changes: country lists, config data
REFERENCE: 86400, // 24 hours
// Static: legal text, help content
STATIC: 604800, // 7 days
};
// Correct usage: setEx (set with expiry) — atomic operation
const redis = await getRedisClient();
await redis.setEx(`user:${userId}`, TTL.STANDARD, JSON.stringify(userData));
// Check remaining TTL on a key (useful for debugging)
const remaining = await redis.ttl(`user:${userId}`);
console.log(`Key expires in ${remaining} seconds`);
// Returns -1 if no TTL set (persistent key — dangerous for cache keys!)
// Returns -2 if key doesn't exist
Track your hit rates by TTL bucket. If a TTL.FREQUENT key has a 20% hit rate, your TTL is too short relative to request frequency — you're doing extra database work for little caching benefit. If a TTL.REFERENCE key returns stale data after schema changes, your TTL is too long. Instrument both miss reasons: expired vs. never-cached.
Manual Invalidation and Event-Driven Cache Busting
TTL handles eventual consistency. But when a user updates their profile, they expect to see the change immediately — not in 59 minutes. That's where explicit invalidation comes in.
// services/userService.js
import { getRedisClient } from '../lib/redisClient.js';
export async function updateUser(userId, data) {
// 1. Update the database first
const updated = await db.query(
'UPDATE users SET name=$1, email=$2 WHERE id=$3 RETURNING *',
[data.name, data.email, userId]
);
// 2. Invalidate stale cache keys
const redis = await getRedisClient().catch(() => null);
if (redis) {
// DEL is synchronous; UNLINK is async (non-blocking) — prefer UNLINK for large values
await redis.unlink(`user:${userId}`);
// Also invalidate aggregates that include this user
await redis.unlink('users:leaderboard');
}
return updated.rows[0];
}
// Bulk invalidation using SCAN (never use KEYS in production — it blocks Redis)
export async function invalidateUserCache(userId) {
const redis = await getRedisClient();
const pattern = `user:${userId}:*`;
let cursor = 0;
const keysToDelete = [];
do {
const result = await redis.scan(cursor, { MATCH: pattern, COUNT: 100 });
cursor = result.cursor;
keysToDelete.push(...result.keys);
} while (cursor !== 0);
if (keysToDelete.length > 0) {
await redis.unlink(keysToDelete);
console.log(`Cache: invalidated ${keysToDelete.length} keys for user:${userId}`);
}
}
For multi-instance deployments, local invalidation isn't enough. If you have three Node.js pods and Pod A updates a user, Pods B and C still hold stale cache entries. Use Redis Pub/Sub to broadcast invalidation events across all instances.
// lib/cacheInvalidator.js — pub/sub invalidation for distributed systems
import { getRedisClient, createSubscriberClient } from './redisClient.js';
const INVALIDATION_CHANNEL = 'cache:invalidate';
// Publisher — call this after any write operation
export async function publishInvalidation(keys) {
const redis = await getRedisClient();
await redis.publish(INVALIDATION_CHANNEL, JSON.stringify({ keys }));
}
// Subscriber — run this on startup in each Node.js instance
export async function startInvalidationListener(localCacheMap) {
const subscriber = await createSubscriberClient();
await subscriber.subscribe(INVALIDATION_CHANNEL, async (message) => {
const { keys } = JSON.parse(message);
const redis = await getRedisClient();
if (keys.length > 0) {
await redis.unlink(keys);
console.log(`Cache: received invalidation for ${keys.length} keys`);
}
});
}
Memory Management and Redis Eviction Policies
Redis will crash with an OOM error if it runs out of memory and you haven't set an eviction policy. Set maxmemory and choose a policy before you deploy to production.
# Check current memory config
redis-cli CONFIG GET maxmemory
redis-cli CONFIG GET maxmemory-policy
# Set in redis.conf or via CLI (also set in docker-compose command)
redis-cli CONFIG SET maxmemory 512mb
redis-cli CONFIG SET maxmemory-policy allkeys-lru
# Monitor memory usage in real-time
redis-cli INFO memory | grep -E "used_memory_human|maxmemory_human|mem_fragmentation_ratio"
The right eviction policy depends on your access patterns:
| Policy | Behavior | Best For |
|---|---|---|
| allkeys-lru | Evict least recently used from all keys | General caching — good default |
| allkeys-lfu | Evict least frequently used from all keys | Hot-spot access patterns |
| volatile-lru | Evict LRU only from keys with TTL set | Mixed cache + persistent data |
| noeviction | Return error when memory is full | Never — will break your app |
For pure caching workloads, allkeys-lru is your default. If you have a small set of extremely hot keys (homepage data, top products), allkeys-lfu will keep those in memory longer. To estimate RAM: multiply your average serialized object size by the number of unique cache keys you expect to hold simultaneously, then add 30% overhead for Redis internals.
Production Patterns, Scaling, and Performance Optimization
Solving the Cache Stampede (Thundering Herd) Problem
Here's the scenario: a high-traffic cache key expires. Before your background refresh runs, 500 concurrent requests all get a cache miss and hammer your database simultaneously. This is a cache stampede. It can take down your database under load even when your Redis infrastructure is healthy.
// WRONG — naive implementation causes stampedes on popular keys
async function getProductNaive(productId) {
const cached = await redis.get(`product:${productId}`);
if (cached) return JSON.parse(cached);
// All 500 concurrent misses end up here simultaneously
const product = await db.query('SELECT * FROM products WHERE id=$1', [productId]);
await redis.setEx(`product:${productId}`, 3600, JSON.stringify(product.rows[0]));
return product.rows[0];
}
// RIGHT — mutex lock using SET NX (atomic "set if not exists")
import { getRedisClient } from '../lib/redisClient.js';
const LOCK_TTL = 30; // seconds — must be longer than your slowest DB query
async function getProductWithLock(productId) {
const redis = await getRedisClient();
const cacheKey = `product:${productId}`;
const lockKey = `lock:${cacheKey}`;
// Try cache first
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
// Try to acquire the lock (NX = only set if key doesn't exist)
const lockAcquired = await redis.set(lockKey, '1', {
NX: true,
EX: LOCK_TTL,
});
if (lockAcquired) {
// This instance won the race — fetch and populate cache
try {
const product = await db.query(
'SELECT * FROM products WHERE id=$1',
[productId]
);
const data = product.rows[0];
await redis.setEx(cacheKey, 3600, JSON.stringify(data));
return data;
} finally {
await redis.unlink(lockKey);
}
} else {
// Another instance is fetching — wait briefly and retry from cache
await new Promise((resolve) => setTimeout(resolve, 50));
const retried = await redis.get(cacheKey);
if (retried) return JSON.parse(retried);
// Last resort: hit DB directly (rare, lock holder should have populated by now)
const product = await db.query(
'SELECT * FROM products WHERE id=$1',
[productId]
);
return product.rows[0];
}
}
A softer approach is probabilistic early expiration: start refreshing a cache key slightly before it expires, using a randomized probability that increases as TTL approaches zero. This avoids hard expiry cliff-edges without locking. For most applications, the mutex approach above is simpler to reason about and debug.
Monitoring Cache Performance and Optimizing Hit Rates
A cache nobody measures is a cache nobody trusts. Track these metrics from day one.
// lib/cacheMetrics.js — instrument cache hits and misses
import { getRedisClient } from './redisClient.js';
export async function getWithMetrics(key, label = 'unknown') {
const redis = await getRedisClient();
const startTime = Date.now();
const value = await redis.get(key);
const duration = Date.now() - startTime;
const hit = value !== null;
// Log to your metrics system (Datadog, Prometheus, CloudWatch)
metrics.histogram('redis.get.duration', duration, { label });
metrics.increment(hit ? 'cache.hit' : 'cache.miss', { label });
if (!hit) {
console.debug(`Cache MISS: ${key} (${label})`);
}
return value ? JSON.parse(value) : null;
}
// Get Redis server-level stats
export async function getCacheStats() {
const redis = await getRedisClient();
const info = await redis.info('stats');
const keyspaceHits = info.match(/keyspace_hits:(\d+)/)?.[1];
const keyspaceMisses = info.match(/keyspace_misses:(\d+)/)?.[1];
const hitRate = (keyspaceHits / (Number(keyspaceHits) + Number(keyspaceMisses))) * 100;
return {
hits: parseInt(keyspaceHits),
misses: parseInt(keyspaceMisses),
hitRatePercent: hitRate.toFixed(2),
};
}
A healthy cache hit rate for a mature application is above 80%. Below 60% means your TTLs are too short, your key naming is causing redundant misses, or you're caching data that changes too frequently to be worth caching. Read the Redis metrics documentation for a full breakdown of what the INFO command exposes.
Redis vs. Memcached and When to Consider Alternatives
Teams still ask this in 2026. The honest answer: use Redis unless you have a specific reason not to.
| Feature | Redis 7.x | Memcached |
|---|---|---|
| Data structures | Strings, hashes, lists, sets, sorted sets, streams | Strings only |
| Persistence | RDB + AOF | None |
| Pub/Sub | Yes | No |
| Clustering | Redis Cluster (native) | Client-side sharding only |
| Multi-threading | I/O threads (v6+) | Full multi-threading |
| Memory efficiency | Good | Slightly better for pure string cache |
Memcached has a marginal memory efficiency edge for simple string caching at extreme scale. Redis wins on every other dimension that matters for a Node.js application. You can also check out our Node.js performance optimization guide for complementary techniques beyond caching.
Frequently Asked Questions
Q: Should I cache at the route level or the service/database level?
A: Both, depending on what you're optimizing. Route-level caching (via middleware) is fastest — you skip all business logic on a hit. Service-level caching is more granular and avoids caching logic bleeding into HTTP concerns. For most applications, start with route-level caching for read-heavy endpoints, then add service-level caching for expensive operations that are called from multiple routes.
Q: What happens to my cache when I deploy a new version of the app with a different data schema?
A: Stale cache entries with old schemas will cause deserialization bugs or silent data corruption. The cleanest solution is versioned cache keys: prefix all keys with a schema version (v2:user:123). On deploy, bump the version and old keys expire naturally via their TTL. Never rely on manual flushes — they're easy to forget and cause downtime if your deployment and flush aren't atomic.
Q: Is it safe to store user session data in Redis?
A: Yes, with caveats. Enable persistence (at minimum RDB snapshots) so sessions survive Redis restarts. Set a reasonable TTL on session keys to prevent unbounded memory growth. Use TLS for Redis connections in production and set a strong password via the requirepass config — default Redis has no authentication. The connect-redis package integrates session storage cleanly with Express.
Wrap-up
Redis caching in Node.js isn't just redis.get and redis.set — it's a set of architectural decisions around invalidation strategy, TTL tuning, eviction policy, and stampede prevention that determine whether your cache helps or creates subtle production bugs. The middleware pattern keeps your route handlers clean, versioned keys prevent schema deployment pain, and the SET NX mutex pattern is the correct fix for thundering herd problems. Start by instrumenting your cache hit rates this week — if you're not measuring it, you're not managing it.
References
- How To Implement Caching in Node.js Using Redis | DigitalOcean
- Redis + Node.js: Introduction to Caching - RisingStack Engineering
- How to Setup Redis Caching in Node - YouTube
- Build a Caching Layer in Node.js With Redis - Semaphore
- Implementing Caching in NodeJS Applications with Redis
- 4 Common Redis Mistakes in Node.js Backends - LinkedIn
Comments
Post a Comment