In This Guide
Redis handles 100,000+ operations per second on a single thread. It's not just fast — its data structures (sorted sets, streams, hashes, HyperLogLog) solve problems that would otherwise require complex application code. This guide covers the patterns we use most in production systems.
1. Redis Data Structures — Your Toolkit
| Structure | Commands | Use Case | Complexity |
|---|---|---|---|
| String | GET, SET, INCR, SETEX | Cache, counters, simple values | O(1) |
| Hash | HGET, HSET, HGETALL | User profiles, object storage | O(1) per field |
| List | LPUSH, RPOP, LRANGE | Queues, recent activity feeds | O(1) push/pop |
| Set | SADD, SISMEMBER, SINTER | Tags, unique visitors, mutual friends | O(1) per op |
| Sorted Set | ZADD, ZRANGEBYSCORE, ZRANK | Leaderboards, priority queues, time series | O(log N) |
| Stream | XADD, XREAD, XREADGROUP | Event sourcing, message queues | O(1) per entry |
| HyperLogLog | PFADD, PFCOUNT | Unique counts (12KB for billions) | O(1) |
2. Caching Patterns That Actually Work
Cache-Aside with Stampede Protection
// Problem: 1000 requests hit cache miss simultaneously → 1000 DB queries
// Solution: Lock + early expiration
async function getProduct(productId) {
const key = `product:${productId}`;
const lockKey = `lock:${key}`;
// Check cache
const cached = await redis.get(key);
if (cached) {
const data = JSON.parse(cached);
// Early refresh: if within 10% of TTL, refresh in background
const ttl = await redis.ttl(key);
if (ttl < 30) { // Less than 30s remaining on a 300s TTL
refreshInBackground(key, productId); // Non-blocking
}
return data;
}
// Cache miss — try to acquire lock (only one request queries DB)
const acquired = await redis.set(lockKey, '1', 'NX', 'EX', 5);
if (acquired) {
// We got the lock — query DB and populate cache
const product = await db.query('SELECT * FROM products WHERE id = $1', [productId]);
await redis.setex(key, 300, JSON.stringify(product));
await redis.del(lockKey);
return product;
}
// Another request is fetching — wait briefly and retry
await sleep(50);
return getProduct(productId); // Retry (will likely hit cache now)
}
Cache Invalidation Strategies
# Strategy 1: Delete on write (simplest, most common)
async function updateProduct(id, data) {
await db.update('products', id, data);
await redis.del(`product:${id}`); // Delete, don't update
await redis.del(`product_list:category:${data.category}`); // Related caches too
}
# Strategy 2: Tag-based invalidation (for complex dependencies)
async function cacheWithTags(key, value, tags, ttl) {
await redis.setex(key, ttl, JSON.stringify(value));
for (const tag of tags) {
await redis.sadd(`tag:${tag}`, key); // Track which keys use this tag
}
}
async function invalidateTag(tag) {
const keys = await redis.smembers(`tag:${tag}`);
if (keys.length) await redis.del(...keys); // Delete all keys with this tag
await redis.del(`tag:${tag}`);
}
// Usage:
await cacheWithTags('product:123', product, ['products', 'category:electronics'], 300);
await invalidateTag('category:electronics'); // Invalidates ALL electronics products
# Strategy 3: Versioned keys (never invalidate — just bump version)
const version = await redis.incr('product_version:123');
const key = `product:123:v${version}`;
await redis.setex(key, 300, JSON.stringify(product));
// Old versioned keys expire naturally via TTL
3. Rate Limiting
Sliding Window Rate Limiter
// Allow 100 requests per minute per user (sliding window)
async function rateLimit(userId, limit = 100, windowSecs = 60) {
const key = `ratelimit:${userId}`;
const now = Date.now();
const windowStart = now - (windowSecs * 1000);
// Atomic operation using a sorted set
const pipe = redis.pipeline();
pipe.zremrangebyscore(key, 0, windowStart); // Remove old entries
pipe.zadd(key, now, `${now}:${Math.random()}`); // Add current request
pipe.zcard(key); // Count requests in window
pipe.expire(key, windowSecs); // Auto-cleanup
const results = await pipe.exec();
const requestCount = results[2][1];
if (requestCount > limit) {
return { allowed: false, remaining: 0, retryAfter: windowSecs };
}
return { allowed: true, remaining: limit - requestCount };
}
// Usage in Express middleware
app.use(async (req, res, next) => {
const result = await rateLimit(req.ip);
res.set('X-RateLimit-Remaining', result.remaining);
if (!result.allowed) {
res.set('Retry-After', result.retryAfter);
return res.status(429).json({ error: 'Rate limit exceeded' });
}
next();
});
4. Distributed Locks
Redlock Pattern — Safe Distributed Locking
// Simple lock (single Redis instance)
async function acquireLock(resource, ttlMs = 5000) {
const lockId = crypto.randomUUID(); // Unique per lock holder
const key = `lock:${resource}`;
// SET NX = only set if not exists, EX = auto-expire
const acquired = await redis.set(key, lockId, 'PX', ttlMs, 'NX');
return acquired ? lockId : null;
}
async function releaseLock(resource, lockId) {
// Lua script — atomic check-and-delete (prevents releasing someone else's lock)
const script = `
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end
`;
await redis.eval(script, 1, `lock:${resource}`, lockId);
}
// Usage: prevent double-processing an order
async function processOrder(orderId) {
const lockId = await acquireLock(`order:${orderId}`, 30000);
if (!lockId) {
console.log('Order already being processed');
return;
}
try {
await chargePayment(orderId);
await updateInventory(orderId);
await sendConfirmation(orderId);
} finally {
await releaseLock(`order:${orderId}`, lockId);
}
}
5. Leaderboards and Ranking
Real-Time Leaderboard with Sorted Sets
# Add/update scores
ZADD leaderboard 1500 "player:alice"
ZADD leaderboard 2300 "player:bob"
ZADD leaderboard 1800 "player:charlie"
ZINCRBY leaderboard 100 "player:alice" # Alice now at 1600
# Top 10 players (highest first)
ZREVRANGE leaderboard 0 9 WITHSCORES
# → bob:2300, charlie:1800, alice:1600
# Get player's rank (0-indexed, highest first)
ZREVRANK leaderboard "player:alice" # → 2 (3rd place)
# Get players ranked 50-60
ZREVRANGE leaderboard 49 59 WITHSCORES
# Weekly leaderboard (auto-expire)
ZADD leaderboard:week:2026-06 1500 "player:alice"
EXPIRE leaderboard:week:2026-06 604800 # 7 days
# Cross-leaderboard: combine daily scores for weekly
ZUNIONSTORE leaderboard:week:2026-06 7 \
leaderboard:day:2026-02-01 leaderboard:day:2026-02-02 ... \
AGGREGATE SUM
6. Session Management
// Hash-based sessions — update individual fields without full serialization
async function createSession(userId) {
const sessionId = crypto.randomUUID();
const key = `session:${sessionId}`;
await redis.hmset(key, {
userId: userId,
createdAt: Date.now(),
lastActivity: Date.now(),
ip: req.ip
});
await redis.expire(key, 86400); // 24-hour TTL
// Track user's active sessions (for "log out all devices")
await redis.sadd(`user_sessions:${userId}`, sessionId);
return sessionId;
}
// Update activity without full read-write cycle
async function touchSession(sessionId) {
await redis.hset(`session:${sessionId}`, 'lastActivity', Date.now());
await redis.expire(`session:${sessionId}`, 86400); // Reset TTL
}
// Log out from all devices
async function logoutAllDevices(userId) {
const sessions = await redis.smembers(`user_sessions:${userId}`);
if (sessions.length) {
await redis.del(...sessions.map(s => `session:${s}`));
}
await redis.del(`user_sessions:${userId}`);
}
7. Pub/Sub and Streams
| Feature | Pub/Sub | Streams |
|---|---|---|
| Persistence | No (fire-and-forget) | Yes (stored on disk) |
| Consumer groups | No | Yes (Kafka-like) |
| Replay | No | Yes (by ID or time) |
| Best for | Real-time notifications, chat | Event sourcing, task queues |
Redis Streams — Lightweight Event Queue
# Producer: add events to stream
XADD orders * customer_id "cust-123" amount "99.99" status "placed"
XADD orders * customer_id "cust-456" amount "149.99" status "placed"
# Create consumer group
XGROUP CREATE orders order-processors $ MKSTREAM
# Consumer: read from group (each message delivered to ONE consumer)
XREADGROUP GROUP order-processors worker-1 COUNT 10 BLOCK 5000 STREAMS orders >
# Acknowledge processed messages
XACK orders order-processors 1234567890-0
# Check pending (unacknowledged) messages
XPENDING orders order-processors
# Claim stuck messages (consumer died without ACK)
XAUTOCLAIM orders order-processors worker-2 60000 0 # Claim messages idle > 60s
8. Operational Best Practices
| Practice | Why | How |
|---|---|---|
| Always set TTLs | Prevents unbounded memory growth | SETEX / EXPIRE on every key |
| Namespace keys | Avoid collisions, enable monitoring | service:entity:id (e.g., "api:user:123") |
| Avoid KEYS command | Blocks Redis (O(N) full scan) | Use SCAN with cursor instead |
| Pipeline commands | Reduces round trips (10x throughput) | redis.pipeline() for batch operations |
| Monitor memory | OOM kills Redis silently | Set maxmemory + eviction policy |
| Use Lua for atomics | Multi-step operations need atomicity | EVAL with Lua scripts |
Frequently Asked Questions
Redis vs Memcached — when to use each?
Redis for almost everything — it has data structures, persistence, pub/sub, scripting, and clustering. Memcached only if you need multi-threaded performance for pure key-value caching at extreme scale and don't need any data structures. In practice, Redis is the right default choice in 2026.
How much memory does Redis need?
Depends on your data. A rough estimate: each key-value pair uses about 100 bytes of overhead plus the actual data size. 1 million simple cached objects (~500 bytes each) needs about 600MB. Use redis-cli INFO memory to see actual usage. Always leave 25-30% headroom for fragmentation and background operations.
Is Redis persistent? Can I use it as a primary database?
Redis has persistence (RDB snapshots + AOF logs), but it's not designed as a primary database. It lacks complex queries, joins, and strong transactional guarantees. Use Redis as a secondary store (cache, queue, session store) alongside a primary database like PostgreSQL. Exception: Redis can be primary for ephemeral data like sessions or rate limits where data loss is acceptable.
Redis Cluster or Redis Sentinel?
Sentinel for high availability without sharding (automatic failover, same dataset on all nodes). Cluster for horizontal scaling (data sharded across nodes, larger datasets). Most applications should start with Sentinel. Move to Cluster when your data doesn't fit in a single Redis instance (typically > 25GB) or you need more than 100K operations/second.
What's the best eviction policy?
allkeys-lru for general caching — evicts least recently used keys when memory is full. volatile-lru if you mix cached (with TTL) and persistent (no TTL) data. noeviction for queues and sessions where losing data is worse than rejecting writes. Never use allkeys-random — it evicts popular keys as readily as cold ones.
Pillai Infotech LLP
We implement Redis caching and real-time data patterns for high-traffic applications. Let's optimize your application's performance.