Module 07: Redis
Goal: Master Redis data structures, caching patterns, pub/sub, rate limiting, and queues. Time: 2-3 days of focused study Prerequisites: Module 01-04
Table of Contents
- What is Redis?
- Redis Setup & CLI
- Data Structures
- Node.js Redis Client
- Caching Patterns
- Session Storage
- Pub/Sub — Real-Time Messaging
- Rate Limiting with Redis
- Queues & Background Jobs
- Redis Streams
- Distributed Locking
- Leaderboards & Rankings
- Redis Best Practices
- Practice Problems
- Interview Questions
1. What is Redis?
Redis (Remote Dictionary Server) is an in-memory data store that can be used as:
- Cache — Speed up database queries
- Database — Primary store for certain data
- Message broker — Pub/Sub for real-time messaging
- Queue — Background job processing
- Session store — User session management
Why Redis Is Fast
Traditional Database: Redis:
Disk → Memory → Process → Return Memory → Process → Return
HDD: ~10ms per read RAM: ~0.1ms per read
SSD: ~0.1ms per read (100x faster than SSD!)
Redis can handle 100,000+ operations per second on a single instance.Key Characteristics
✅ In-memory (extremely fast)
✅ Single-threaded (no locking issues, atomic operations)
✅ Persistence options (snapshots, append-only file)
✅ Rich data structures (not just key-value!)
✅ TTL (time-to-live) support on any key
✅ Pub/Sub messaging
✅ Lua scripting (atomic multi-step operations)
✅ Clustering and replication2. Redis Setup & CLI
# Install Redis
# macOS: brew install redis
# Ubuntu: sudo apt install redis-server
# Windows: Use Docker or WSL
# Docker: docker run -d -p 6379:6379 redis
# Start Redis server
redis-server
# Connect with CLI
redis-cli
# Basic commands
redis-cli ping # PONG (server is alive)
redis-cli SET name "Alice"
redis-cli GET name # "Alice"Essential CLI Commands
# Key-value basics
SET key value # Set a key
GET key # Get value
DEL key # Delete key
EXISTS key # 1 if exists, 0 if not
TYPE key # Type of value stored
# Expiration
SET session:123 "data" EX 3600 # Expires in 3600 seconds
EXPIRE key 60 # Set expiry on existing key
TTL key # Time remaining (-1 = no expiry, -2 = expired/deleted)
PERSIST key # Remove expiry
# Key patterns
KEYS user:* # Find keys matching pattern (⚠️ SLOW, avoid in production!)
SCAN 0 MATCH user:* COUNT 100 # Iterator-based alternative (production-safe)
# Database management
SELECT 0 # Switch to database 0 (default, 0-15 available)
FLUSHDB # Delete all keys in current database
FLUSHALL # Delete all keys in ALL databases
DBSIZE # Number of keys
INFO # Server information3. Data Structures
Redis has 6 main data structures. Understanding when to use each is crucial.
1. Strings — The Simplest
# Store any value: string, number, JSON, binary (max 512MB)
SET user:1:name "Alice"
GET user:1:name # "Alice"
# Numeric operations (string that looks like a number)
SET counter 10
INCR counter # 11 (atomic increment)
INCRBY counter 5 # 16
DECR counter # 15
DECRBY counter 3 # 12
INCRBYFLOAT counter 0.5 # 12.5
# Multiple key operations
MSET key1 "val1" key2 "val2" key3 "val3"
MGET key1 key2 key3 # ["val1", "val2", "val3"]
# Set only if not exists (useful for locking)
SETNX lock:resource "owner1" # 1 (set, key didn't exist)
SETNX lock:resource "owner2" # 0 (not set, key already exists)
# Set with expiry in one command
SET session:abc "data" EX 3600 NX # Set with 1-hour expiry, only if not exists
# Append
APPEND greeting "Hello" # "Hello"
APPEND greeting " World" # "Hello World"Use cases: Caching, counters, rate limiting, simple values, session IDs
2. Lists — Ordered Sequences
# Lists are doubly-linked lists — fast insert/remove at both ends
LPUSH queue "task1" # Push to LEFT (head)
LPUSH queue "task2" # [task2, task1]
RPUSH queue "task3" # [task2, task1, task3]
LPOP queue # "task2" (remove from left)
RPOP queue # "task3" (remove from right)
# Blocking pop (waits until data is available — great for queues!)
BLPOP queue 30 # Wait up to 30 seconds for an element
LRANGE queue 0 -1 # Get all elements
LRANGE queue 0 9 # Get first 10 elements
LLEN queue # Length
LINDEX queue 0 # Element at index 0
LTRIM queue 0 99 # Keep only first 100 elements (cap list size)Use cases: Message queues, activity feeds, recent items, task queues
3. Sets — Unique Unordered Collections
SADD tags:post:1 "javascript" "nodejs" "redis"
SADD tags:post:2 "javascript" "react" "css"
SMEMBERS tags:post:1 # {"javascript", "nodejs", "redis"}
SISMEMBER tags:post:1 "redis" # 1 (true)
SCARD tags:post:1 # 3 (cardinality/size)
SREM tags:post:1 "redis" # Remove element
# Set operations
SINTER tags:post:1 tags:post:2 # {"javascript"} (intersection)
SUNION tags:post:1 tags:post:2 # {"javascript", "nodejs", "redis", "react", "css"}
SDIFF tags:post:1 tags:post:2 # {"nodejs", "redis"} (in 1 but not in 2)
SRANDMEMBER tags:post:1 2 # Get 2 random members
SPOP tags:post:1 # Remove and return random memberUse cases: Tags, unique visitors, mutual friends, online users, voting
4. Sorted Sets — Sets with Scores
# Each member has a score — sorted by score automatically
ZADD leaderboard 100 "alice"
ZADD leaderboard 250 "bob"
ZADD leaderboard 175 "charlie"
ZADD leaderboard 300 "diana"
# Get by rank (0-indexed, ascending)
ZRANGE leaderboard 0 -1 # ["alice", "charlie", "bob", "diana"]
ZRANGE leaderboard 0 -1 WITHSCORES # With scores
# Get by rank (descending — top players first)
ZREVRANGE leaderboard 0 2 # ["diana", "bob", "charlie"] (top 3)
ZREVRANGE leaderboard 0 2 WITHSCORES
# Get rank
ZRANK leaderboard "bob" # 2 (0-indexed, ascending)
ZREVRANK leaderboard "bob" # 1 (0-indexed, descending)
# Get score
ZSCORE leaderboard "bob" # 250
# Increment score
ZINCRBY leaderboard 50 "alice" # alice now has 150
# Count and range by score
ZCOUNT leaderboard 100 200 # Members with score 100-200
ZRANGEBYSCORE leaderboard 100 200 # Get those members
# Remove
ZREM leaderboard "alice"
ZREMRANGEBYRANK leaderboard 0 0 # Remove lowest-ranked memberUse cases: Leaderboards, priority queues, time-series data, delayed tasks
5. Hashes — Objects/Maps
# Like a mini key-value store inside a key — perfect for objects
HSET user:1 name "Alice" age "25" email "alice@example.com" role "admin"
HGET user:1 name # "Alice"
HGETALL user:1 # {name: "Alice", age: "25", ...}
HMGET user:1 name email # ["Alice", "alice@example.com"]
HSET user:1 age 26 # Update a field
HDEL user:1 email # Delete a field
HEXISTS user:1 name # 1 (true)
HLEN user:1 # Number of fields
HKEYS user:1 # All field names
HVALS user:1 # All values
HINCRBY user:1 age 1 # Increment numeric field
HINCRBYFLOAT user:1 balance 10.50Use cases: User profiles, configuration, any object-like data, counters per entity
6. Streams — Append-Only Log
# Like a persistent, consumer-group-aware message queue
XADD mystream * sensor "temp" value "22.5"
# Returns: "1705312000000-0" (timestamp-based ID)
XADD mystream * sensor "temp" value "23.1"
XADD mystream * sensor "humidity" value "65"
# Read messages
XRANGE mystream - + # All messages
XRANGE mystream - + COUNT 10 # First 10 messages
XLEN mystream # Number of messages
# Consumer groups (for distributed processing)
XGROUP CREATE mystream mygroup 0 # Create group starting from beginning
XREADGROUP GROUP mygroup consumer1 COUNT 1 BLOCK 5000 STREAMS mystream >
# Read 1 new message, wait up to 5 seconds
XACK mystream mygroup "1705312000000-0" # Acknowledge message processedUse cases: Event sourcing, activity logging, message queues with delivery guarantees
4. Node.js Redis Client
// npm install redis
const { createClient } = require('redis');
// ---- CONNECTION ----
const client = createClient({
url: 'redis://localhost:6379',
// Or with password:
// url: 'redis://:password@localhost:6379'
socket: {
reconnectStrategy: (retries) => {
if (retries > 10) return new Error('Max reconnect attempts reached');
return Math.min(retries * 100, 3000); // Exponential backoff
},
},
});
client.on('error', (err) => console.error('Redis error:', err));
client.on('connect', () => console.log('Redis connected'));
client.on('reconnecting', () => console.log('Redis reconnecting...'));
await client.connect();
// ---- STRINGS ----
await client.set('key', 'value');
await client.set('key', 'value', { EX: 3600 }); // With expiry
const value = await client.get('key');
await client.incr('counter');
await client.incrBy('counter', 5);
// ---- HASHES ----
await client.hSet('user:1', {
name: 'Alice',
email: 'alice@example.com',
age: '25',
});
const user = await client.hGetAll('user:1');
// { name: 'Alice', email: 'alice@example.com', age: '25' }
const name = await client.hGet('user:1', 'name');
await client.hIncrBy('user:1', 'loginCount', 1);
// ---- LISTS ----
await client.lPush('queue', 'task1');
await client.rPush('queue', 'task2');
const task = await client.lPop('queue');
const items = await client.lRange('queue', 0, -1);
// ---- SETS ----
await client.sAdd('online', 'user1', 'user2', 'user3');
const isOnline = await client.sIsMember('online', 'user1');
const onlineUsers = await client.sMembers('online');
// ---- SORTED SETS ----
await client.zAdd('leaderboard', [
{ score: 100, value: 'alice' },
{ score: 250, value: 'bob' },
{ score: 175, value: 'charlie' },
]);
const top3 = await client.zRangeWithScores('leaderboard', 0, 2, { REV: true });
// [{ value: 'bob', score: 250 }, { value: 'charlie', score: 175 }, ...]
const rank = await client.zRevRank('leaderboard', 'bob'); // 0 (top)
// ---- EXPIRY ----
await client.expire('key', 60); // Expire in 60 seconds
const ttl = await client.ttl('key'); // Time remaining
// ---- TRANSACTIONS (MULTI/EXEC) ----
// Execute multiple commands atomically
const results = await client
.multi()
.set('key1', 'val1')
.set('key2', 'val2')
.incr('counter')
.exec();
// All commands execute together or none do
// ---- CLEANUP ----
await client.quit(); // Graceful close
// or await client.disconnect(); // Force close5. Caching Patterns
Cache-Aside (Lazy Loading) — Most Common
// The application manages the cache manually
async function getUser(userId) {
const cacheKey = `user:${userId}`;
// 1. Check cache
const cached = await redis.get(cacheKey);
if (cached) {
console.log('Cache HIT');
return JSON.parse(cached);
}
// 2. Cache miss — fetch from database
console.log('Cache MISS');
const user = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
if (!user) return null;
// 3. Store in cache for next time
await redis.set(cacheKey, JSON.stringify(user), { EX: 3600 }); // 1 hour TTL
return user;
}
// Update: invalidate cache when data changes
async function updateUser(userId, data) {
await db.query('UPDATE users SET name = $1 WHERE id = $2', [data.name, userId]);
await redis.del(`user:${userId}`); // Invalidate cache
// Next read will fetch fresh data from DB and re-cache
}Write-Through Cache
// Every write goes to BOTH cache and database
async function createUser(data) {
const user = await db.query(
'INSERT INTO users (name, email) VALUES ($1, $2) RETURNING *',
[data.name, data.email]
);
// Update cache immediately
await redis.set(`user:${user.id}`, JSON.stringify(user), { EX: 3600 });
return user;
}Write-Behind Cache (Write-Back)
// Write to cache immediately, sync to database later (in batches)
// Good for: counters, analytics, non-critical data
async function incrementPageView(pageId) {
// Fast: increment in Redis
await redis.incr(`views:${pageId}`);
// Database update happens later via background job
}
// Background job (runs every minute)
async function syncViewsToDB() {
const keys = await redis.keys('views:*'); // Use SCAN in production
for (const key of keys) {
const pageId = key.split(':')[1];
const views = await redis.getSet(key, '0'); // Get and reset
if (parseInt(views) > 0) {
await db.query(
'UPDATE pages SET views = views + $1 WHERE id = $2',
[parseInt(views), pageId]
);
}
}
}Cache Stampede Prevention
// Problem: When a popular cache key expires, 1000 requests hit the DB simultaneously
// Solution: Lock to ensure only one request rebuilds the cache
async function getWithLock(key, fetchFn, ttl = 3600) {
// Try cache first
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
// Try to acquire lock
const lockKey = `lock:${key}`;
const acquired = await redis.set(lockKey, '1', { EX: 10, NX: true }); // 10-second lock
if (acquired) {
try {
// This request rebuilds the cache
const data = await fetchFn();
await redis.set(key, JSON.stringify(data), { EX: ttl });
return data;
} finally {
await redis.del(lockKey); // Release lock
}
} else {
// Another request is rebuilding — wait and retry
await new Promise(resolve => setTimeout(resolve, 100));
return getWithLock(key, fetchFn, ttl); // Retry
}
}
// Usage:
const user = await getWithLock(
`user:${userId}`,
() => db.query('SELECT * FROM users WHERE id = $1', [userId]),
3600
);6. Session Storage
// Using Redis as session store (see Module 05 for full implementation)
const session = require('express-session');
const RedisStore = require('connect-redis').default;
app.use(session({
store: new RedisStore({ client: redisClient }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: { secure: true, httpOnly: true, maxAge: 86400000 },
}));
// What happens in Redis:
// SET "sess:sessionId123" '{"userId":1,"role":"admin"}' EX 86400
// GET "sess:sessionId123" → {"userId":1,"role":"admin"}
// DEL "sess:sessionId123" → logout7. Pub/Sub
Pub/Sub lets you send messages between different parts of your system in real-time.
// ---- PUBLISHER ----
const publisher = createClient({ url: 'redis://localhost:6379' });
await publisher.connect();
// Publish a message to a channel
await publisher.publish('notifications', JSON.stringify({
type: 'order_created',
orderId: 123,
userId: 456,
}));
await publisher.publish('chat:room:general', JSON.stringify({
from: 'alice',
message: 'Hello everyone!',
}));
// ---- SUBSCRIBER ----
const subscriber = createClient({ url: 'redis://localhost:6379' });
await subscriber.connect();
// Subscribe to channels
await subscriber.subscribe('notifications', (message) => {
const data = JSON.parse(message);
console.log('Notification:', data);
});
// Pattern subscribe (wildcard)
await subscriber.pSubscribe('chat:room:*', (message, channel) => {
const room = channel.split(':')[2];
const data = JSON.parse(message);
console.log(`[${room}] ${data.from}: ${data.message}`);
});
// ⚠️ IMPORTANT: A subscriber client CANNOT be used for other commands!
// You need separate clients for pub and sub.Real-World: Cross-Server Event Broadcasting
// Use pub/sub to sync events across multiple server instances
// event-bus.js
class RedisEventBus {
constructor(redisUrl) {
this.publisher = createClient({ url: redisUrl });
this.subscriber = createClient({ url: redisUrl });
this.handlers = new Map();
}
async connect() {
await Promise.all([
this.publisher.connect(),
this.subscriber.connect(),
]);
}
async publish(event, data) {
await this.publisher.publish(event, JSON.stringify(data));
}
async subscribe(event, handler) {
if (!this.handlers.has(event)) {
this.handlers.set(event, []);
await this.subscriber.subscribe(event, (message) => {
const data = JSON.parse(message);
this.handlers.get(event).forEach(h => h(data));
});
}
this.handlers.get(event).push(handler);
}
}
// Usage across your application:
const eventBus = new RedisEventBus(process.env.REDIS_URL);
await eventBus.connect();
// Server 1: Publish when user signs up
eventBus.publish('user:registered', { userId: 1, email: 'alice@test.com' });
// Server 2: Listen and send welcome email
eventBus.subscribe('user:registered', async (data) => {
await sendWelcomeEmail(data.email);
});
// Server 3: Listen and create analytics event
eventBus.subscribe('user:registered', async (data) => {
await analytics.track('signup', { userId: data.userId });
});8. Rate Limiting with Redis
Fixed Window
async function rateLimit(userId, limit = 100, windowSecs = 60) {
const key = `ratelimit:${userId}:${Math.floor(Date.now() / 1000 / windowSecs)}`;
const current = await redis.incr(key);
if (current === 1) {
await redis.expire(key, windowSecs);
}
return {
allowed: current <= limit,
remaining: Math.max(0, limit - current),
resetAt: Math.ceil(Date.now() / 1000 / windowSecs) * windowSecs,
};
}Sliding Window (More Accurate)
async function slidingWindowRateLimit(userId, limit = 100, windowMs = 60000) {
const key = `ratelimit:${userId}`;
const now = Date.now();
const windowStart = now - windowMs;
// Use sorted set: score = timestamp, value = unique ID
const pipeline = redis.multi();
// Remove old entries outside the window
pipeline.zRemRangeByScore(key, 0, windowStart);
// Count remaining entries
pipeline.zCard(key);
// Add current request
pipeline.zAdd(key, { score: now, value: `${now}-${Math.random()}` });
// Set expiry on the key
pipeline.expire(key, Math.ceil(windowMs / 1000));
const results = await pipeline.exec();
const count = results[1]; // zCard result
return {
allowed: count < limit,
remaining: Math.max(0, limit - count - 1),
};
}Token Bucket (Most Flexible)
async function tokenBucket(userId, maxTokens = 10, refillRate = 1, refillIntervalMs = 1000) {
const key = `bucket:${userId}`;
// Lua script for atomicity
const script = `
local key = KEYS[1]
local max_tokens = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local refill_interval = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or max_tokens
local last_refill = tonumber(bucket[2]) or now
-- Calculate tokens to add
local elapsed = now - last_refill
local refills = math.floor(elapsed / refill_interval)
tokens = math.min(max_tokens, tokens + refills * refill_rate)
last_refill = last_refill + refills * refill_interval
if tokens >= 1 then
tokens = tokens - 1
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', last_refill)
redis.call('EXPIRE', key, math.ceil(max_tokens / refill_rate * refill_interval / 1000) + 1)
return {1, tokens}
else
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', last_refill)
return {0, tokens}
end
`;
const result = await redis.eval(script, {
keys: [key],
arguments: [
maxTokens.toString(),
refillRate.toString(),
refillIntervalMs.toString(),
Date.now().toString(),
],
});
return {
allowed: result[0] === 1,
remaining: result[1],
};
}9. Queues & Background Jobs
Simple Queue with Lists
// Producer — add jobs to queue
async function addJob(queueName, jobData) {
const job = {
id: crypto.randomUUID(),
data: jobData,
createdAt: Date.now(),
};
await redis.rPush(`queue:${queueName}`, JSON.stringify(job));
return job.id;
}
// Worker — process jobs from queue
async function processQueue(queueName, handler) {
console.log(`Worker listening on queue: ${queueName}`);
while (true) {
// BLPOP blocks until a job is available (timeout: 30s)
const result = await redis.blPop(`queue:${queueName}`, 30);
if (!result) continue; // Timeout — try again
const job = JSON.parse(result.element);
console.log(`Processing job: ${job.id}`);
try {
await handler(job.data);
console.log(`Job ${job.id} completed`);
} catch (err) {
console.error(`Job ${job.id} failed:`, err);
// Move to dead letter queue
await redis.rPush(`queue:${queueName}:failed`, JSON.stringify({
...job,
error: err.message,
failedAt: Date.now(),
}));
}
}
}
// Usage:
// Producer (in your API route):
await addJob('email', { to: 'alice@example.com', subject: 'Welcome!' });
// Worker (separate process):
processQueue('email', async (data) => {
await sendEmail(data.to, data.subject);
});Using BullMQ (Production-Ready Queue)
// npm install bullmq
const { Queue, Worker } = require('bullmq');
const connection = { host: 'localhost', port: 6379 };
// ---- PRODUCER ----
const emailQueue = new Queue('email', { connection });
// Add a job
await emailQueue.add('welcome', {
to: 'alice@example.com',
subject: 'Welcome!',
template: 'welcome',
}, {
attempts: 3, // Retry 3 times on failure
backoff: {
type: 'exponential',
delay: 1000, // 1s, 2s, 4s between retries
},
removeOnComplete: 100, // Keep last 100 completed jobs
removeOnFail: 1000, // Keep last 1000 failed jobs
priority: 1, // Lower number = higher priority
delay: 60000, // Delay execution by 1 minute
});
// Scheduled/repeating jobs
await emailQueue.add('daily-digest', {}, {
repeat: {
pattern: '0 9 * * *', // Every day at 9 AM (cron)
},
});
// ---- WORKER (separate process) ----
const worker = new Worker('email', async (job) => {
console.log(`Processing job ${job.name} (${job.id})`);
switch (job.name) {
case 'welcome':
await sendWelcomeEmail(job.data.to);
break;
case 'daily-digest':
await sendDailyDigest();
break;
}
// Return value is stored as job result
return { sent: true, timestamp: Date.now() };
}, {
connection,
concurrency: 5, // Process 5 jobs simultaneously
limiter: {
max: 10, // Max 10 jobs
duration: 1000, // Per second
},
});
worker.on('completed', (job, result) => {
console.log(`Job ${job.id} completed:`, result);
});
worker.on('failed', (job, err) => {
console.error(`Job ${job.id} failed:`, err.message);
});10. Redis Streams
Streams are like a persistent, consumer-group-aware message queue.
// ---- PRODUCER ----
// Add events to a stream
await redis.xAdd('events', '*', {
type: 'page_view',
userId: '123',
page: '/dashboard',
timestamp: Date.now().toString(),
});
// ---- CONSUMER (Simple) ----
// Read new events
const events = await redis.xRead(
{ key: 'events', id: '$' }, // $ = only new events
{ COUNT: 10, BLOCK: 5000 } // Wait up to 5 seconds
);
// ---- CONSUMER GROUP (Distributed) ----
// Create consumer group
await redis.xGroupCreate('events', 'analytics-group', '0', { MKSTREAM: true });
// Consumer reads from group
const messages = await redis.xReadGroup(
'analytics-group', // Group name
'consumer-1', // Consumer name
{ key: 'events', id: '>' }, // '>' = undelivered messages
{ COUNT: 10, BLOCK: 5000 }
);
// Acknowledge processing
if (messages) {
for (const [stream, entries] of messages) {
for (const { id, message } of entries) {
// Process message
console.log('Event:', message);
// Acknowledge (remove from pending)
await redis.xAck('events', 'analytics-group', id);
}
}
}11. Distributed Locking
// Ensure only one instance processes a task at a time
class RedisLock {
constructor(client) {
this.client = client;
}
async acquire(resource, ttlMs = 10000) {
const lockId = crypto.randomUUID();
const key = `lock:${resource}`;
const acquired = await this.client.set(key, lockId, {
PX: ttlMs, // Milliseconds
NX: true, // Only if not exists
});
if (acquired) {
return lockId; // Return lock ID for safe release
}
return null; // Failed to acquire
}
async release(resource, lockId) {
const key = `lock:${resource}`;
// Only release if we own the lock (Lua script for atomicity)
const script = `
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
`;
return await this.client.eval(script, {
keys: [key],
arguments: [lockId],
});
}
async withLock(resource, fn, ttlMs = 10000) {
const lockId = await this.acquire(resource, ttlMs);
if (!lockId) throw new Error(`Could not acquire lock on ${resource}`);
try {
return await fn();
} finally {
await this.release(resource, lockId);
}
}
}
// Usage:
const lock = new RedisLock(redis);
await lock.withLock('process-payment:order-123', async () => {
// Only ONE instance can execute this at a time
await processPayment(orderId);
});12. Leaderboards & Rankings
// Perfect use case for Sorted Sets
class Leaderboard {
constructor(client, name) {
this.client = client;
this.key = `leaderboard:${name}`;
}
async addScore(userId, score) {
await this.client.zAdd(this.key, { score, value: userId });
}
async incrementScore(userId, points) {
return await this.client.zIncrBy(this.key, points, userId);
}
async getTop(count = 10) {
const results = await this.client.zRangeWithScores(this.key, 0, count - 1, { REV: true });
return results.map((entry, index) => ({
rank: index + 1,
userId: entry.value,
score: entry.score,
}));
}
async getRank(userId) {
const rank = await this.client.zRevRank(this.key, userId);
const score = await this.client.zScore(this.key, userId);
return rank !== null ? { rank: rank + 1, score } : null;
}
async getAroundUser(userId, range = 2) {
const rank = await this.client.zRevRank(this.key, userId);
if (rank === null) return null;
const start = Math.max(0, rank - range);
const end = rank + range;
const results = await this.client.zRangeWithScores(this.key, start, end, { REV: true });
return results.map((entry, index) => ({
rank: start + index + 1,
userId: entry.value,
score: entry.score,
isCurrentUser: entry.value === userId,
}));
}
}
// Usage:
const lb = new Leaderboard(redis, 'weekly');
await lb.addScore('alice', 100);
await lb.addScore('bob', 250);
await lb.incrementScore('alice', 50);
console.log(await lb.getTop(10));
console.log(await lb.getRank('alice'));
console.log(await lb.getAroundUser('alice'));13. Redis Best Practices
1. KEY NAMING CONVENTION
Use colons as separators: user:123:profile, session:abc123
Use meaningful prefixes: cache:, lock:, queue:, rate:
Keep keys short (Redis stores them in memory)
2. ALWAYS SET TTL
Every key should have an expiry unless it's permanent data
Prevents Redis from running out of memory
3. USE PIPELINES FOR BULK OPERATIONS
await client.multi()
.set('key1', 'val1')
.set('key2', 'val2')
.exec();
Reduces round trips (1 instead of N)
4. AVOID KEYS COMMAND IN PRODUCTION
KEYS * scans ALL keys — blocks Redis
Use SCAN instead (cursor-based, non-blocking)
5. DON'T STORE LARGE VALUES
Keep values under 100KB
For large data, store in DB and cache metadata in Redis
6. USE THE RIGHT DATA STRUCTURE
Need uniqueness? → Set
Need ordering? → Sorted Set or List
Need fields? → Hash
Need simple value? → String
7. HANDLE CONNECTION FAILURES
Implement reconnection logic
Have fallback behavior when Redis is down
8. MONITOR MEMORY USAGE
redis-cli INFO memory
Set maxmemory and eviction policy:
CONFIG SET maxmemory 256mb
CONFIG SET maxmemory-policy allkeys-lru14. Practice Problems
Problem 1: Build a URL Shortener Cache
// Implement caching for a URL shortener (like Snips!)
// Cache short URL → long URL mappings
class URLShortenerCache {
constructor(redisClient) {
this.client = redisClient;
}
async cacheUrl(shortCode, longUrl, ttl = 86400) {
await this.client.set(`url:${shortCode}`, longUrl, { EX: ttl });
}
async getUrl(shortCode) {
return await this.client.get(`url:${shortCode}`);
}
async incrementClicks(shortCode) {
const key = `clicks:${shortCode}`;
const today = new Date().toISOString().split('T')[0];
await this.client.multi()
.incr(`${key}:total`)
.incr(`${key}:daily:${today}`)
.exec();
}
async getClickStats(shortCode) {
const total = await this.client.get(`clicks:${shortCode}:total`);
return { total: parseInt(total) || 0 };
}
}Problem 2: Implement a Session Manager
// Build a session manager using Redis hashes
class SessionManager {
constructor(client, ttlSeconds = 3600) {
this.client = client;
this.ttl = ttlSeconds;
}
async create(userId, metadata = {}) {
const sessionId = crypto.randomUUID();
const key = `session:${sessionId}`;
await this.client.hSet(key, {
userId: userId.toString(),
createdAt: Date.now().toString(),
...Object.fromEntries(
Object.entries(metadata).map(([k, v]) => [k, String(v)])
),
});
await this.client.expire(key, this.ttl);
// Track user's sessions
await this.client.sAdd(`user_sessions:${userId}`, sessionId);
return sessionId;
}
async get(sessionId) {
const data = await this.client.hGetAll(`session:${sessionId}`);
return Object.keys(data).length ? data : null;
}
async destroy(sessionId) {
const data = await this.get(sessionId);
if (data) {
await this.client.del(`session:${sessionId}`);
await this.client.sRem(`user_sessions:${data.userId}`, sessionId);
}
}
async destroyAllForUser(userId) {
const sessions = await this.client.sMembers(`user_sessions:${userId}`);
if (sessions.length) {
const keys = sessions.map(s => `session:${s}`);
await this.client.del(keys);
await this.client.del(`user_sessions:${userId}`);
}
}
}15. Interview Questions
Q1: What is Redis? Why is it fast?
Answer: Redis is an in-memory data store that can function as a cache, database, and message broker. It's fast because data is stored in RAM (not disk), it's single-threaded (no locking overhead), and it uses efficient data structures. It can handle 100,000+ operations per second.
Q2: What Redis data structure would you use for a leaderboard?
Answer: Sorted Set (ZSET). Each member has a score, and Redis keeps them sorted automatically. Use ZREVRANGE for top-N players, ZREVRANK for a user's rank, ZINCRBY to update scores. All operations are O(log N). For a weekly leaderboard, use a key like leaderboard:weekly:2024-W03 with TTL.
Q3: How would you implement rate limiting with Redis?
Answer: Multiple approaches: (1) Fixed window — use INCR with EXPIRE, counting requests per time window. (2) Sliding window — use a Sorted Set with timestamps as scores, removing old entries outside the window. (3) Token bucket — use a Hash to track tokens and last refill time. Sliding window is most accurate; token bucket is most flexible.
Q4: Explain Redis Pub/Sub vs Streams.
Answer: Pub/Sub is fire-and-forget — messages go to currently connected subscribers only. If a subscriber is offline, it misses messages. No persistence, no consumer groups. Streams are persistent (like Kafka lite) — messages are stored and can be read by consumer groups with acknowledgment. Use Pub/Sub for real-time events where missing messages is okay. Use Streams when you need delivery guarantees.
Q5: What caching strategy would you use and why?
Answer: Cache-Aside (Lazy Loading) is most common — check cache, miss → query DB → store in cache. Write-Through for data that's read immediately after write. Write-Behind for high-write scenarios like counters. Always set TTL to prevent stale data. Use cache stampede prevention (locking) for popular keys.
Q6: What happens when Redis runs out of memory?
Answer: Depends on the maxmemory-policy: noeviction rejects writes, allkeys-lru removes least recently used keys, volatile-lru removes LRU keys with TTL only, allkeys-random removes random keys. For caches, allkeys-lru is recommended. Always set maxmemory in production.
Next Module: 08 - Testing, Debugging & DevOps — Jest, Docker, CI/CD.