Caching trades freshness, memory, and operational complexity for lower latency and less origin load. It is valuable only when the workload has enough reuse and the application has an explicit plan for misses, stale values, failures, and invalidation.
This guide uses Redis Open Source 8.10 terminology and covers the data types, caching patterns, and failure modes that appear in backend and system-design interviews.
Table of Contents
- Caching Fundamentals Questions
- Redis Data Structures Questions
- Caching Patterns Questions
- Cache Invalidation Questions
- Redis in Node.js Questions
- Session and Rate Limiting Questions
- Scaling Redis Questions
Caching Fundamentals Questions
Before diving into Redis specifics, understand the core concepts that apply to any caching system.
Why should you use caching?
Caching dramatically improves application performance by storing frequently accessed data in a fast-access layer. Instead of repeatedly fetching data from slow sources like databases or external APIs, applications can retrieve cached copies in a fraction of the time.
The math behind caching makes it essential for any system at scale. If your cache hit rate is 90% and cache responds in 2ms while database responds in 100ms, your average response time is: 0.9 * 2ms + 0.1 * 100ms = 11.8ms instead of 100ms.
| Problem | Without Cache | With Cache |
|---|---|---|
| Database query | 50-200ms | 1-5ms |
| API call to external service | 100-500ms | 1-5ms |
| Complex computation | Varies | Instant (precomputed) |
| Database load | Every request hits DB | Only cache misses hit DB |
What are cache hits and cache misses?
A cache hit occurs when the requested data is found in the cache and can be returned immediately. A cache miss occurs when the data isn't in the cache, requiring a fetch from the original source. The ratio between hits and misses determines how effective your caching strategy is.
Understanding these metrics helps you tune your caching system. High miss rates indicate your cache isn't holding the right data or expires too quickly, while high eviction rates suggest your cache is too small for your working set.
flowchart LR
REQ["Request"] --> CHECK["Check Cache"]
CHECK -->|"Hit"| RETURN1["Return cached data"]
CHECK -->|"Miss"| FETCH["Fetch from source"]
FETCH --> STORE["Store in cache"]
STORE --> RETURN2["Return data"]Key metrics:
- Hit rate: Percentage of eligible requests served from cache; the useful target is workload-specific
- Miss rate: Percentage requiring origin fetch
- Latency: Time to retrieve from cache vs origin
- Eviction rate: How often items are removed to make space
What types of data are good candidates for caching?
Not all data benefits equally from caching. The best candidates are frequently accessed data that changes infrequently and where slight staleness is acceptable. The worst candidates are rapidly changing data that requires strong consistency.
When evaluating what to cache, consider the access pattern, update frequency, and tolerance for stale data. A user's profile information accessed thousands of times per hour is an excellent candidate. Real-time stock prices that change every second are poor candidates.
Good candidates for caching:
- Frequently accessed data (hot data)
- Expensive computations
- Data that changes infrequently
- Data where slight staleness is acceptable
Poor candidates:
- Rapidly changing data
- Data requiring strong consistency
- User-specific data with low reuse
- Large objects with low access frequency
Redis Data Structures Questions
Redis isn't just a key-value store. Its data structures are why it's powerful. Each has specific use cases and performance characteristics.
How do Redis Strings work and when should you use them?
Redis Strings are the simplest and most versatile data type—a key maps to a byte sequence, with a default maximum value size of 512 MB. Strings support atomic operations such as increments and conditional updates.
Strings fit basic caching, counters, and lock primitives. A lock needs more than SET NX EX: use a unique owner token, a finite lease, and compare-and-delete on release. Decide whether a single Redis instance's failure guarantees are sufficient; asynchronous failover can violate mutual exclusion.
// Basic operations
await redis.set('user:1:name', 'Alice');
const name = await redis.get('user:1:name');
// With expiration (TTL in seconds)
await redis.set('session:abc123', JSON.stringify(sessionData), 'EX', 3600);
// Atomic increment (counters)
await redis.incr('page:home:views');
await redis.incrby('user:1:points', 10);
// Acquire a single-instance lock lease with a unique owner token
const token = crypto.randomUUID();
const acquired = await redis.set('lock:resource', token, 'NX', 'PX', 30_000);Use cases: Simple caching, counters, distributed locks, session storage.
How do Redis Hashes work and when should you use them?
Redis Hashes store a collection of field-value pairs under a single key—like a mini key-value store within a key. This structure allows you to read or write individual fields without serializing or deserializing the entire object.
Hashes are more memory-efficient than storing objects as JSON strings when you frequently need to access individual fields. Instead of fetching and parsing a large JSON blob to read one property, you can retrieve just the field you need.
// Store object fields individually
await redis.hset('user:1', {
name: 'Alice',
email: 'alice@example.com',
points: '100'
});
// Get single field (efficient for partial reads)
const email = await redis.hget('user:1', 'email');
// Get all fields
const user = await redis.hgetall('user:1');
// Increment a field
await redis.hincrby('user:1', 'points', 10);Use cases: Object storage when you need field-level access, user profiles, configuration.
How do Redis Lists work and when should you use them?
Redis Lists are ordered collections of strings with fast O(1) operations at the head and tail. They fit simple queues, stacks, and recent activity feeds where access is primarily at the ends.
Lists support blocking operations like BLPOP that wait for items to arrive, making them useful for simple queues without polling. A destructive pop has no built-in acknowledgement or pending-entry recovery, so use Redis Streams with consumer groups or a dedicated queue when reliable processing, retries, and ownership matter.
// Add to list (queue pattern)
await redis.rpush('queue:emails', JSON.stringify(email));
// Pop from list (worker pattern)
const email = await redis.lpop('queue:emails');
// Blocking pop (wait for item)
const [key, value] = await redis.blpop('queue:emails', 30); // 30s timeout
// Get range (recent items)
const recentPosts = await redis.lrange('user:1:posts', 0, 9); // Last 10
// Trim list (keep only recent)
await redis.ltrim('user:1:activity', 0, 99); // Keep last 100Use cases: Job queues, recent activity feeds, message buffers.
How do Redis Sets work and when should you use them?
Redis Sets are unordered collections of unique strings with O(1) membership checking. They automatically handle deduplication and provide powerful set operations like intersection, union, and difference that operate server-side.
Sets excel at tracking unique items and finding relationships between collections. You can use set operations to find mutual friends, common tags, or users who have visited multiple pages—all without transferring data to your application.
// Add members
await redis.sadd('post:1:tags', 'javascript', 'nodejs', 'redis');
// Check membership (O(1))
const isTagged = await redis.sismember('post:1:tags', 'redis');
// Get all members
const tags = await redis.smembers('post:1:tags');
// Set operations
await redis.sinter('user:1:following', 'user:2:following'); // Mutual follows
await redis.sunion('post:1:tags', 'post:2:tags'); // All tags
await redis.sdiff('user:1:following', 'user:2:following'); // Unique to user 1
// Count unique items
await redis.sadd('page:home:visitors', visitorId);
const uniqueVisitors = await redis.scard('page:home:visitors');Use cases: Tags, unique visitor tracking, social graph relationships, deduplication.
How do Redis Sorted Sets work and when should you use them?
Redis Sorted Sets combine the uniqueness of sets with ordering by score. Each member has an associated numeric score that determines its position, with O(log n) operations for insertion and range queries by score or rank.
Sorted Sets are the go-to structure for leaderboards, priority queues, and time-series data. Using timestamps as scores lets you efficiently query events within time ranges. Using numeric rankings lets you retrieve top-N items or find any member's position.
// Add with scores
await redis.zadd('leaderboard',
100, 'player:1',
250, 'player:2',
175, 'player:3'
);
// Get top players
const topPlayers = await redis.zrevrange('leaderboard', 0, 9, 'WITHSCORES');
// Get player rank (0-indexed)
const rank = await redis.zrevrank('leaderboard', 'player:2');
// Increment score
await redis.zincrby('leaderboard', 10, 'player:1');
// Range by score (time-based queries)
await redis.zadd('events', Date.now(), JSON.stringify(event));
const recentEvents = await redis.zrangebyscore('events',
Date.now() - 3600000, // 1 hour ago
Date.now()
);Use cases: Leaderboards, priority queues, time-series data, rate limiting.
How do you choose the right Redis data structure?
Choosing the right data structure depends on your access patterns and the operations you need to perform. Using the wrong structure leads to inefficient memory usage and slower operations, while the right choice gives you O(1) or O(log n) performance for your most common operations.
Consider what questions you'll ask of your data. Need to check if an item exists? Use a Set. Need to maintain order by score? Use a Sorted Set. Need to access individual object fields? Use a Hash.
| Need | Structure | Why |
|---|---|---|
| Simple cache | String | Straightforward, supports TTL |
| Object with field access | Hash | Read/write individual fields |
| FIFO queue | List | O(1) push/pop at ends |
| Unique items | Set | Automatic deduplication |
| Ranking/scoring | Sorted Set | Ordered by score, range queries |
Caching Patterns Questions
Different patterns suit different consistency and performance requirements.
What is the cache-aside pattern and how does it work?
Cache-aside, also called lazy loading, is the most common caching pattern where the application manages the cache directly. The application first checks the cache, and on a miss, fetches from the database and populates the cache before returning the data.
This pattern is simple to implement and gives you full control over what gets cached. Cache failures don't break the application—they just result in slower database queries. The main drawback is the cache miss penalty, which requires three round trips: check cache, fetch from database, and populate cache.
async function getUser(userId) {
const cacheKey = `user:${userId}`;
// 1. Check cache
const cached = await redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// 2. Cache miss - fetch from database
const user = await db.users.findById(userId);
// 3. Populate cache
if (user) {
await redis.set(cacheKey, JSON.stringify(user), 'EX', 3600);
}
return user;
}Pros: Simple, only caches what's needed, cache failures don't break the app.
Cons: Cache miss penalty (three round trips), potential for stale data.
What is the read-through caching pattern?
Read-through caching places the cache between the application and database, with the cache handling misses automatically. Instead of the application managing cache population, a caching library or proxy intercepts reads and loads data on misses.
This pattern results in cleaner application code since the caching logic is abstracted away. However, you lose some control over caching behavior and need to configure the cache's data loading mechanism upfront.
// Conceptual - typically handled by caching library
const cache = new ReadThroughCache({
get: (key) => redis.get(key),
set: (key, value, ttl) => redis.set(key, value, 'EX', ttl),
load: async (key) => {
// Called automatically on cache miss
const userId = key.replace('user:', '');
return db.users.findById(userId);
}
});
// Usage - cache handles miss automatically
const user = await cache.get(`user:${userId}`);Pros: Cleaner application code, consistent miss handling.
Cons: More complex setup, less control over caching logic.
What is the write-through caching pattern?
Write-through caching routes a write through a caching layer that synchronously updates the authoritative store before acknowledging success. A hand-written “database write, then Redis write” is only a dual-write sequence: the second step can fail, and concurrent cache fills can still publish stale data.
The trade-off is increased write latency since every write requires two operations. This pattern also caches all written data regardless of whether it will be read, potentially wasting cache space on data that's written but rarely accessed.
async function updateUser(userId, data) {
const cacheKey = `user:${userId}`;
// Write to database
const user = await db.users.update(userId, data);
// Write to cache (synchronously)
await redis.set(cacheKey, JSON.stringify(user), 'EX', 3600);
return user;
}Pros: A single write path can reduce stale reads and centralize cache policy.
Cons: Higher write latency, failure coordination, race handling, and caching data that might not be read.
What is the write-behind (write-back) caching pattern?
Write-behind caching writes to the cache immediately and queues database writes for asynchronous processing. This provides the fastest write performance since the application doesn't wait for the database, but introduces complexity and eventual consistency.
The risk with write-behind is data loss—if the cache fails before the background worker processes the write queue, data may be lost. Use this pattern only when write performance is critical and you can tolerate some data loss or have implemented additional safeguards.
async function updateUser(userId, data) {
const cacheKey = `user:${userId}`;
// Write to cache immediately
await redis.set(cacheKey, JSON.stringify(data), 'EX', 3600);
// Queue database write for async processing
await redis.rpush('write:queue', JSON.stringify({
type: 'user:update',
userId,
data
}));
return data;
}
// Background worker processes writes
async function processWriteQueue() {
while (true) {
const item = await redis.blpop('write:queue', 0);
const { type, userId, data } = JSON.parse(item[1]);
await db.users.update(userId, data);
}
}Pros: Fast writes, reduced database load.
Cons: Complexity, data loss risk if cache fails before DB write, eventual consistency.
How do the caching patterns compare?
Each caching pattern makes different trade-offs between read latency, write latency, consistency, and implementation complexity. The right choice depends on your application's specific requirements and tolerance for stale data.
Cache-aside is a common starting point due to its simplicity and flexibility. Write-through centralizes the write path but still needs an explicit failure protocol. Write-behind optimizes acknowledgement latency but requires durable queuing, idempotency, ordering, and recovery from partial failures.
| Pattern | Read Latency | Write Latency | Consistency | Complexity |
|---|---|---|---|---|
| Cache-Aside | Miss penalty | N/A | Eventual | Low |
| Read-Through | Miss penalty | N/A | Eventual | Medium |
| Write-Through | Low | Higher | Depends on failure protocol | Medium–High |
| Write-Behind | Low | Low | Eventual | High |
Cache Invalidation Questions
"There are only two hard things in Computer Science: cache invalidation and naming things." —Phil Karlton
How does TTL-based cache expiration work?
TTL (Time-To-Live) based expiration is the simplest invalidation approach—data automatically expires after a configured time period. Redis handles the expiration automatically, removing keys when their TTL reaches zero.
This approach requires minimal code and handles cleanup automatically, but you must choose TTL values carefully. Too short and you lose caching benefits. Too long and users see stale data. The right TTL depends on how frequently the underlying data changes and how stale data tolerance your application has.
// Set TTL on write
await redis.set('user:1', userData, 'EX', 3600); // 1 hour
// Set TTL on existing key
await redis.expire('user:1', 3600);
// Check remaining TTL
const ttl = await redis.ttl('user:1');Pros: Simple, automatic cleanup.
Cons: Stale data until expiration, choosing right TTL is tricky.
How does event-driven cache invalidation work?
Event-driven invalidation reacts to a source-data change by deleting or updating related cache entries. It can shorten stale windows, but it does not guarantee immediate consistency: delivery can fail, and a concurrent cache miss can read an old database snapshot and repopulate stale data after the invalidation.
Redis Pub/Sub can notify online application instances, but messages are not persisted for disconnected subscribers. When invalidation must survive failures, publish from a transactional outbox to a durable stream or broker, make consumers idempotent, and retain a TTL as a safety bound.
// On user update
async function updateUser(userId, data) {
await db.users.update(userId, data);
// Invalidate all related caches
await redis.del(`user:${userId}`);
await redis.del(`user:${userId}:profile`);
await redis.del(`user:${userId}:permissions`);
}
// Or use pub/sub for distributed invalidation
async function updateUser(userId, data) {
await db.users.update(userId, data);
await redis.publish('cache:invalidate', JSON.stringify({
pattern: `user:${userId}:*`
}));
}Pros: Usually reduces staleness compared with TTL alone.
Cons: Delivery and ordering failures, fill/invalidation races, and key-dependency tracking.
How does version-based cache invalidation work?
Version-based invalidation includes a version number in cache keys and increments the version to invalidate all existing cached data. Old versions become orphaned and eventually expire, while new requests use the updated version.
This approach eliminates the need to find and delete specific cache keys. When you increment the version, all old keys are effectively invalidated without any explicit deletion. The downside is temporary memory overhead from old versions that haven't expired yet.
async function getUser(userId) {
// Get current version
const version = await redis.get(`user:${userId}:version`) || '1';
const cacheKey = `user:${userId}:v${version}`;
const cached = await redis.get(cacheKey);
if (cached) return JSON.parse(cached);
const user = await db.users.findById(userId);
await redis.set(cacheKey, JSON.stringify(user), 'EX', 86400);
return user;
}
async function invalidateUser(userId) {
// Increment version - old keys will be ignored and eventually expire
await redis.incr(`user:${userId}:version`);
}Pros: No need to find and delete keys, old versions expire naturally.
Cons: Temporary memory overhead from old versions.
What is a cache stampede and how do you prevent it?
A cache stampede occurs when a popular cache key expires and many concurrent requests simultaneously hit the database to regenerate it. This can overwhelm the database and cause cascading failures, especially for expensive queries or computations.
Prevention strategies include locking (only one request regenerates while others wait), probabilistic early expiration (randomly refresh before TTL expires), and background refresh (proactively regenerate before expiration). The right approach depends on your traffic patterns and tolerance for serving stale data.
Solution 1: Locking
async function getWithLock(key, fetchFn, ttl = 3600) {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const lockKey = `lock:${key}`;
const token = crypto.randomUUID();
const lockAcquired = await redis.set(lockKey, token, 'NX', 'PX', 10_000);
if (lockAcquired) {
try {
const data = await fetchFn();
await redis.set(key, JSON.stringify(data), 'EX', ttl);
return data;
} finally {
// Redis 8.4+: delete only if this caller still owns the lease.
await redis.call('DELEX', lockKey, 'IFEQ', token);
}
}
// Real code needs a bounded wait with jitter, timeout, and fallback policy.
throw new Error('Regeneration already in progress');
}The lease must outlive regeneration or be safely extended. A failover-based lock can still lose mutual exclusion because Redis replication is asynchronous; use fencing tokens or a coordination system with the guarantees required by the protected resource.
Solution 2: Probabilistic Early Expiration
async function getWithEarlyRefresh(key, fetchFn, ttl = 3600) {
const cached = await redis.get(key);
const keyTtl = await redis.ttl(key);
if (cached) {
// Randomly refresh if TTL is low (last 10%)
const shouldRefresh = keyTtl < ttl * 0.1 && Math.random() < 0.1;
if (shouldRefresh) {
// Refresh in background, return stale data
fetchFn().then(data => {
redis.set(key, JSON.stringify(data), 'EX', ttl);
});
}
return JSON.parse(cached);
}
const data = await fetchFn();
await redis.set(key, JSON.stringify(data), 'EX', ttl);
return data;
}Redis in Node.js Questions
Practical patterns for using Redis in Node.js applications.
How do you set up a Redis connection in Node.js?
ioredis is one maintained Node.js client with standalone, Sentinel, and Cluster support. Connection behavior during outages is part of application correctness: retry budgets, offline queues, timeouts, and idempotency determine whether the service sheds load or replays a backlog.
Use a secret-managed connection URL and TLS (rediss://) where required, prefer ACL users over a shared default password, handle errors, and expose readiness and latency metrics. Do not retry non-idempotent commands blindly after an ambiguous network failure.
import Redis from 'ioredis';
// Standalone or Sentinel endpoint supplied by the deployment
const redis = new Redis(process.env.REDIS_URL, {
connectTimeout: 5_000,
maxRetriesPerRequest: 3,
retryStrategy: attempt => Math.min(attempt * 100, 2_000)
});
// Cluster
const cluster = new Redis.Cluster([
{ host: 'node1', port: 6379 },
{ host: 'node2', port: 6379 },
{ host: 'node3', port: 6379 }
]);
// Handle connection events
redis.on('error', (err) => console.error('Redis error:', err));
redis.on('connect', () => console.log('Redis connected'));What is Redis pipelining and why should you use it?
Pipelining sends multiple commands to Redis without waiting for individual responses, reducing network round trips. Instead of sending one command and waiting for its response before sending the next, you batch commands together and receive all responses at once.
This technique can improve throughput when executing multiple independent commands by amortizing network round trips. The gain depends on network latency, command cost, batch size, and client buffering; pipelining is not atomic and should use bounded batches to avoid excessive memory and response latency.
// Without pipelining: 3 round trips
await redis.set('key1', 'value1');
await redis.set('key2', 'value2');
await redis.set('key3', 'value3');
// With pipelining: 1 round trip
const pipeline = redis.pipeline();
pipeline.set('key1', 'value1');
pipeline.set('key2', 'value2');
pipeline.set('key3', 'value3');
await pipeline.exec();
// Get multiple values
const pipeline = redis.pipeline();
pipeline.get('user:1');
pipeline.get('user:2');
pipeline.get('user:3');
const results = await pipeline.exec();
// results = [[null, 'user1data'], [null, 'user2data'], [null, 'user3data']]How do Redis transactions work?
MULTI queues commands and EXEC runs them sequentially without another client's command interleaving. This is isolation during execution, not database-style all-or-nothing success: a command can fail at execution time while later queued commands still run, and Redis does not roll successful commands back. A queue-time error causes EXEC to refuse the transaction.
For optimistic locking, use WATCH to monitor keys before starting a transaction. If any watched key changes before EXEC, the transaction aborts. This pattern is useful for read-modify-write operations where you need to ensure the data hasn't changed between reading and writing.
// MULTI/EXEC transaction
const result = await redis.multi()
.incr('counter')
.get('counter')
.exec();
// All commands execute atomically
// Watch for optimistic locking
await redis.watch('balance');
const balance = parseInt(await redis.get('balance'));
if (balance >= amount) {
const result = await redis.multi()
.decrby('balance', amount)
.rpush('transactions', JSON.stringify({ amount, date: Date.now() }))
.exec();
if (result === null) {
// A watched key changed: retry the complete read/validate/EXEC flow.
}
} else {
await redis.unwatch();
throw new Error('Insufficient balance');
}How do you use Lua scripts in Redis?
Lua scripts execute without interleaving from other clients, providing a way to combine conditional logic and Redis commands server-side. Atomic does not mean rollback: a runtime error can occur after earlier writes. Long-running scripts block command processing, so scripts should stay short and deterministic.
SCRIPT LOAD returns a SHA hash for EVALSHA, avoiding repeated script text. Clients must handle NOSCRIPT after restart or failover by loading the script again. Redis Functions are another server-side programmability option for managed code.
// Rate limiter in Lua (atomic)
const rateLimitScript = `
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local current = redis.call('INCR', key)
if current == 1 then
redis.call('EXPIRE', key, window)
end
if current > limit then
return 0
end
return 1
`;
// Load and use
const rateLimitSha = await redis.script('LOAD', rateLimitScript);
async function checkRateLimit(userId, limit = 100, windowSeconds = 60) {
const key = `ratelimit:${userId}:${Math.floor(Date.now() / 1000 / windowSeconds)}`;
const allowed = await redis.evalsha(rateLimitSha, 1, key, limit, windowSeconds);
return allowed === 1;
}Session and Rate Limiting Questions
Two common Redis use cases with practical implementations.
How do you implement session storage with Redis?
Redis is a common session store because it provides fast access, TTLs, replication, and persistence options. Shared sessions can remove the need for sticky routing, but authentication state still needs secure cookies, rotation/revocation rules, capacity planning, and a policy for Redis outages or failover data loss.
For Express applications, the connect-redis package integrates with express-session to handle session serialization and storage automatically. For more control, you can manage sessions manually with simple get/set operations and explicit TTL management.
import session from 'express-session';
import RedisStore from 'connect-redis';
// Express session with Redis
app.use(session({
store: new RedisStore({ client: redis }),
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV === 'production',
maxAge: 24 * 60 * 60 * 1000 // 24 hours
}
}));
// Manual session management
async function createSession(userId) {
const sessionId = crypto.randomUUID();
const sessionData = {
userId,
createdAt: Date.now(),
lastAccess: Date.now()
};
await redis.set(
`session:${sessionId}`,
JSON.stringify(sessionData),
'EX',
86400
);
return sessionId;
}
async function getSession(sessionId) {
const data = await redis.get(`session:${sessionId}`);
if (!data) return null;
// Refresh TTL on access
await redis.expire(`session:${sessionId}`, 86400);
return JSON.parse(data);
}How do you implement fixed window rate limiting?
Fixed window rate limiting tracks requests within discrete time windows. When a window starts, the counter resets to zero. This approach is simple to implement but allows bursts at window boundaries—a user could make the maximum requests at the end of one window and again at the start of the next.
The counter increment and its first expiration must be atomic. Redis Open Source 8.8 introduced INCREX, which combines increment, bounds, and expiration in one command. On older versions, use a short Lua script; separate INCR and EXPIRE calls can leave an immortal key if the client fails between them.
async function fixedWindowRateLimit(userId, limit = 100, windowSeconds = 60) {
const window = Math.floor(Date.now() / 1000 / windowSeconds);
const key = `ratelimit:${userId}:${window}`;
const [current, applied] = await redis.call(
'INCREX', key,
'BYINT', 1,
'UBOUND', limit,
'EX', windowSeconds,
'ENX'
);
return {
allowed: applied === 1,
remaining: Math.max(0, limit - current),
resetAt: (window + 1) * windowSeconds * 1000
};
}How do you implement sliding window rate limiting?
Sliding window rate limiting provides smoother rate enforcement by considering both the current and previous time windows. It weights the previous window's count by how much of the current window has elapsed, creating a rolling average that prevents boundary bursts.
This approximation balances smoothness with resource usage. It is not the same as an exact sliding log. The read, decision, increment, and expiry must execute atomically under concurrency, so the multi-command JavaScript below is conceptual; implement the operation with a Lua script or a tested rate-limit library.
async function slidingWindowRateLimit(userId, limit = 100, windowSeconds = 60) {
const now = Date.now(); // Prefer Redis TIME inside the atomic script.
const windowMs = windowSeconds * 1000;
const currentWindow = Math.floor(now / windowMs);
const previousWindow = currentWindow - 1;
const currentKey = `ratelimit:${userId}:${currentWindow}`;
const previousKey = `ratelimit:${userId}:${previousWindow}`;
const [currentCount, previousCount] = await redis.mget(currentKey, previousKey);
// Weight previous window by how much of current window has passed
const elapsedInCurrentWindow = now % windowMs;
const previousWeight = 1 - (elapsedInCurrentWindow / windowMs);
const count = (parseInt(currentCount) || 0) +
(parseInt(previousCount) || 0) * previousWeight;
if (count >= limit) {
return { allowed: false, remaining: 0 };
}
await redis.incr(currentKey);
await redis.expire(currentKey, windowSeconds * 2);
return {
allowed: true,
remaining: Math.floor(limit - count - 1)
};
}How do you implement token bucket rate limiting?
Token bucket rate limiting allows controlled bursts while enforcing an average rate. A bucket holds tokens that refill at a constant rate. Each request consumes tokens, and requests are denied when the bucket is empty. This allows short bursts up to the bucket size while maintaining the average rate over time.
The implementation requires atomic operations to handle concurrent requests correctly. A Lua script ensures that checking the token count, calculating refill, and consuming tokens happens atomically without race conditions.
async function tokenBucketRateLimit(
userId,
bucketSize = 10,
refillRate = 1, // tokens per second
tokensRequired = 1
) {
const key = `bucket:${userId}`;
const now = Date.now();
// Lua script for atomic token bucket
const script = `
local key = KEYS[1]
local bucket_size = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2])
local tokens_required = tonumber(ARGV[3])
local now = tonumber(ARGV[4])
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or bucket_size
local last_refill = tonumber(bucket[2]) or now
-- Refill tokens based on time passed
local elapsed = (now - last_refill) / 1000
tokens = math.min(bucket_size, tokens + (elapsed * refill_rate))
if tokens >= tokens_required then
tokens = tokens - tokens_required
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, math.ceil(bucket_size / refill_rate * 2))
return {1, tokens}
else
return {0, tokens}
end
`;
const [allowed, remaining] = await redis.eval(
script, 1, key, bucketSize, refillRate, tokensRequired, now
);
return { allowed: allowed === 1, remaining };
}Scaling Redis Questions
Operations questions are part of Redis interviews because persistence, memory pressure, and failover change application semantics.
How do RDB and AOF persistence differ?
Redis can run without persistence for a disposable cache, create point-in-time RDB snapshots, append write commands to AOF, or enable both. RDB is compact and useful for backups and faster restarts, but a crash can lose writes since the latest snapshot. AOF usually offers a smaller loss window, depending on its fsync policy, but uses more disk and performs rewrites.
Persistence is not a backup strategy by itself. Replicate and test restores, keep copies outside the failure domain, and choose an RPO/RTO deliberately. With both AOF and RDB enabled, Redis uses AOF on restart because it is normally the more complete dataset.
| Mode | Main benefit | Main trade-off |
|---|---|---|
| No persistence | Lowest persistence overhead for disposable cache | Dataset is lost on restart |
| RDB | Compact snapshots and convenient backups | Writes after the last snapshot can be lost |
| AOF | Configurable durability window and replay | Larger files, fsync/rewrite overhead |
| RDB + AOF | Recovery plus snapshot backup benefits | More operational and resource cost |
How do maxmemory and eviction policies work?
maxmemory limits dataset memory considered for eviction. When a write pushes usage over the limit, Redis applies maxmemory-policy: for example, allkeys-lru, allkeys-lfu, random eviction, TTL-scoped volatile-* policies, or noeviction, which rejects writes that need more memory. LRU and LFU are approximations, not exact global ordering. Redis 8.6 also added least-recently-modified policies (allkeys-lrm and volatile-lrm).
For a pure cache, an allkeys-* policy is often easier to reason about than a volatile policy that can evict only keys with TTLs. For mixed durable and cache data, separate Redis deployments are safer than relying on one eviction policy. Leave headroom for replication/AOF buffers and monitor used_memory, RSS fragmentation, evictions, rejected writes, and hot keys.
How does Redis replication work?
Redis replication creates copies of data on replicas that follow a primary. The primary handles writes, and replicas asynchronously receive updates. Replicas can serve stale reads, and reading from them introduces consistency choices rather than free scaling.
Replication is asynchronous. A primary can acknowledge a write before a replica receives it, so failover may lose acknowledged writes. Reading from the primary avoids replica lag but does not by itself make failover durable; persistence, topology, and application-level recovery still matter.
flowchart TB
MASTER["Primary<br/><i>← Writes</i>"]
R1["Replica"]
R2["Replica"]
R3["Replica"]
MASTER -->|"Replication"| R1
MASTER -->|"Replication"| R2
MASTER -->|"Replication"| R3
subgraph READS["← Reads distributed"]
R1
R2
R3
end// ioredis with read replicas
const redis = new Redis({
sentinels: [
{ host: 'sentinel1', port: 26379 },
{ host: 'sentinel2', port: 26379 }
],
name: 'mymaster',
role: 'slave', // ioredis API name for a replica connection
preferredSlaves: [
{ ip: 'replica1', port: 6379, prio: 1 },
{ ip: 'replica2', port: 6379, prio: 2 }
]
});What is Redis Sentinel and how does it work?
Redis Sentinel monitors primary and replica nodes, notifies operators, provides service discovery, and coordinates promotion of a replica when a quorum determines the primary is unavailable. It improves availability but does not make split brain or acknowledged-write loss impossible under partitions and asynchronous replication.
Sentinel provides monitoring, notification, service discovery, and coordinated failover. Compatible clients ask Sentinel for the current primary and reconnect after topology changes.
flowchart TB
subgraph SENTINEL["Sentinel Cluster"]
S1["Sentinel1"]
S2["Sentinel2"]
S3["Sentinel3"]
end
MASTER["Primary"]
REPLICA1["Replica"]
REPLICA2["Replica"]
S1 -->|"Monitor"| MASTER
S2 -->|"Monitor"| MASTER
S3 -->|"Monitor"| MASTER
MASTER --> REPLICA1
MASTER --> REPLICA2Sentinel provides:
- Monitoring the primary and replicas
- Coordinated failover when the primary is judged unavailable
- Configuration provider for clients
What is Redis Cluster and how does it work?
Redis Cluster provides horizontal scaling by sharding data across multiple primary nodes. Each primary owns part of the 16,384 hash slots that partition the keyspace. Clients follow MOVED/ASK redirects and must understand the cluster topology.
Each primary can have replicas for availability within its shard. Eligible replicas may be promoted after failure. Adding or removing nodes does not magically rebalance keys: an operator or cluster-management tool must move hash slots, and multi-key operations generally require all keys to share a slot.
flowchart TB
subgraph CLUSTER["Redis Cluster"]
subgraph SHARD1["Shard 1"]
M1["Primary 1<br/>Slots 0-5460"]
R1["Replica 1"]
M1 --> R1
end
subgraph SHARD2["Shard 2"]
M2["Primary 2<br/>Slots 5461-10922"]
R2["Replica 2"]
M2 --> R2
end
subgraph SHARD3["Shard 3"]
M3["Primary 3<br/>Slots 10923-16383"]
R3["Replica 3"]
M3 --> R3
end
endKey concepts:
- 16384 hash slots distributed across primaries
- Keys hashed to slots:
CRC16(key) % 16384 - Each primary owns a set of slots
- Automatic resharding when adding/removing nodes
// Hash tags for related keys on same slot
await redis.set('{user:1}:profile', profileData);
await redis.set('{user:1}:settings', settingsData);
// Both keys hash based on {user:1}, ensuring same slotWhat is the difference between Redis Sentinel and Redis Cluster?
Sentinel and Cluster solve different topology problems. Sentinel adds monitoring, discovery, and failover to one unsharded primary-replica group. Cluster distributes slots across multiple primaries and also supports replica promotion per shard.
Choose Sentinel when one primary can handle the dataset and write load and unsharded commands are valuable. Choose Cluster when you need sharded capacity or throughput and can design keys and multi-key operations around hash slots. Neither removes the need to test failure and recovery behavior.
| Aspect | Sentinel | Cluster |
|---|---|---|
| Purpose | High availability | Horizontal scaling |
| Data distribution | One unsharded primary dataset | Sharded across primaries |
| Max data size | Single node memory | Combined node memory |
| Automatic failover | Yes | Yes |
| Multi-key operations | All keys accessible | Only same-slot keys |
Quick Reference
Data structure selection:
- Simple values → Strings
- Objects with field access → Hashes
- Queues → Lists
- Unique items → Sets
- Ranked data → Sorted Sets
Caching patterns:
- Most cases → Cache-aside
- Need consistency → Write-through
- High write volume → Write-behind
Invalidation strategies:
- Simple → TTL-based
- Needs consistency → Event-driven
- Avoid key tracking → Version-based
Prevent stampede:
- Request coalescing or a correctly owned lock lease for one regeneration path
- Probabilistic early refresh for high traffic
- Background refresh for critical data
Scaling:
- Read scaling → Replication
- High availability → Sentinel
- Data scaling → Cluster
Durability and memory:
- Disposable cache → persistence may be disabled deliberately
- Recovery → choose and test RDB/AOF plus external backups
- Bounded memory → configure
maxmemoryand an explicit eviction policy
Official Sources
- Redis Open Source release notes
- Redis data types
- Redis 8.8 and the Array type
- Redis persistence
- Key eviction
- Transactions
- Distributed locks
INCREX- Redis replication
- Redis Sentinel
- Redis Cluster
- Connecting with
ioredis
Related Articles
- Complete Node.js Backend Developer Interview Guide - Full guide to backend interviews
- System Design Interview Guide - Caching in system design context
- MongoDB Interview Guide - Database caching strategies
- Web Performance Interview Guide - Frontend caching patterns
Frequently Asked Questions
What is cache-aside pattern and when should you use it?
Cache-aside (lazy loading) means the application checks the cache first, and on a miss, fetches from the database and populates the cache. Use it when: reads far exceed writes, you can tolerate stale data briefly, and cache misses are acceptable. It's the most common pattern because it's simple and the application controls exactly what gets cached.
What are the main Redis data structures and their use cases?
Core types include strings for values and counters, hashes for field-value records, lists for end-oriented sequences, sets for unique membership, and sorted sets for score ordering. Redis 8.10 also includes arrays, streams, JSON, time series, vector sets, geospatial indexes, and probabilistic types. Complexity depends on the exact command and result size, not just the type.
How do you handle cache invalidation?
Common strategies include bounded staleness with TTL, deleting or updating cache entries after a committed source write, and versioned keys. None creates strong consistency by itself: invalidation events can be lost or reordered and concurrent readers can repopulate stale data. Choose a consistency model, make operations idempotent, add retries or an outbox where needed, and monitor failures.
What is a cache stampede and how do you prevent it?
A cache stampede occurs when many requests regenerate the same missing or expired hot key. Mitigations include in-process request coalescing, a correctly owned and time-bounded distributed lock, stale-while-revalidate, probabilistic early refresh, and TTL jitter. Waiting must be bounded, and the origin still needs backpressure and overload protection.
What is the difference between Redis Cluster and Redis Sentinel?
Redis Sentinel monitors a primary-replica deployment, helps clients discover the current primary, and coordinates failover without sharding the dataset. Redis Cluster distributes 16,384 hash slots across multiple primaries and can promote replicas per shard. Both use asynchronous replication, so acknowledged writes can still be lost during some failures.
How do you implement rate limiting with Redis?
Common approaches are fixed window, exact sliding log, approximate sliding counter, and token bucket. The decision and update must be atomic under concurrency. Redis 8.8+ offers INCREX for an atomic bounded counter with expiration; more complex algorithms can use a short Lua script or a tested library, preferably using Redis server time.
