35+ Redis & Caching Interview Questions 2025: Data Structures, Patterns & Scaling

·26 min read
rediscachingbackendnodejsperformanceinterview-preparation

Caching is how you make slow things fast. When your database query takes 100ms and your cache lookup takes 1ms, caching isn't an optimization—it's a requirement for any system at scale.

Redis is the dominant caching solution in backend development. Understanding its data structures, caching patterns, and failure modes is expected knowledge for backend interviews. This guide covers caching concepts and Redis specifics that come up in backend and system design interviews.

Table of Contents

  1. Caching Fundamentals Questions
  2. Redis Data Structures Questions
  3. Caching Patterns Questions
  4. Cache Invalidation Questions
  5. Redis in Node.js Questions
  6. Session and Rate Limiting Questions
  7. 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.

ProblemWithout CacheWith Cache
Database query50-200ms1-5ms
API call to external service100-500ms1-5ms
Complex computationVariesInstant (precomputed)
Database loadEvery request hits DBOnly 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 requests served from cache (target: 90%+)
  • 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 structure—a key maps to a single value up to 512MB. Despite their simplicity, strings support atomic operations like increment and conditional setting that make them powerful for many use cases.

Strings are ideal for basic caching, counters, and distributed locking. The SET command with NX (only set if not exists) and EX (expiration) options provides the building blocks for implementing distributed locks across multiple application instances.

// 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);
 
// Set only if not exists (distributed locking)
const acquired = await redis.set('lock:resource', 'owner1', 'NX', 'EX', 30);

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. This makes them perfect for implementing queues, stacks, and recent activity feeds where you're primarily adding and removing from the ends.

Lists support blocking operations like BLPOP that wait for items to arrive, making them useful for building job queues without polling. Workers can block until work is available, reducing unnecessary CPU usage and latency.

// 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 100

Use 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.

NeedStructureWhy
Simple cacheStringStraightforward, supports TTL
Object with field accessHashRead/write individual fields
FIFO queueListO(1) push/pop at ends
Unique itemsSetAutomatic deduplication
Ranking/scoringSorted SetOrdered 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 writes data to both the cache and database synchronously on every write operation. This ensures the cache is always consistent with the database immediately after any write, eliminating stale reads after updates.

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: Cache always consistent with database, no stale reads after writes.

Cons: Write latency (two operations), caches 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 the default choice for most applications due to its simplicity and flexibility. Write-through adds consistency guarantees at the cost of write performance. Write-behind optimizes for write-heavy workloads but requires careful handling of failure scenarios.

PatternRead LatencyWrite LatencyConsistencyComplexity
Cache-AsideMiss penaltyN/AEventualLow
Read-ThroughMiss penaltyN/AEventualMedium
Write-ThroughLowHigherStrongMedium
Write-BehindLowLowEventualHigh

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 removes cached data immediately when the source data changes. This provides immediate consistency but requires tracking all cache keys related to a piece of data and invalidating them on every update.

For distributed systems, you can use Redis pub/sub to broadcast invalidation events to all application instances. This ensures all caches stay synchronized when any instance updates data.

// 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: Immediate consistency.

Cons: Complexity, must track all cache keys to invalidate.

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 lockAcquired = await redis.set(lockKey, '1', 'NX', 'EX', 10);
 
  if (lockAcquired) {
    try {
      const data = await fetchFn();
      await redis.set(key, JSON.stringify(data), 'EX', ttl);
      return data;
    } finally {
      await redis.del(lockKey);
    }
  } else {
    // Wait and retry
    await sleep(50);
    return getWithLock(key, fetchFn, ttl);
  }
}

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?

The ioredis library is the most popular Redis client for Node.js, supporting both single instances and clusters. It handles connection management, reconnection, and provides a promise-based API that integrates well with async/await.

Configure connection options based on your environment—development typically uses localhost while production uses environment variables for host, port, and authentication. Always handle connection events to monitor Redis health and catch connection errors early.

import Redis from 'ioredis';
 
// Single instance
const redis = new Redis({
  host: 'localhost',
  port: 6379,
  password: process.env.REDIS_PASSWORD,
  db: 0,
  retryDelayOnFailover: 100,
  maxRetriesPerRequest: 3
});
 
// 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 dramatically improves performance when executing multiple independent commands. Without pipelining, three commands require three round trips. With pipelining, the same three commands complete in a single round trip, reducing latency by roughly two-thirds.

// 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?

Redis transactions using MULTI/EXEC execute multiple commands atomically—either all commands succeed or none do. Unlike database transactions, Redis transactions don't support rollback, but they guarantee no other client's commands will interleave with your 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) {
  await redis.multi()
    .decrby('balance', amount)
    .rpush('transactions', JSON.stringify({ amount, date: Date.now() }))
    .exec();
} else {
  await redis.unwatch();
  throw new Error('Insufficient balance');
}

How do you use Lua scripts in Redis?

Lua scripts execute atomically on the Redis server, providing a way to implement complex operations that can't be done with individual commands. Scripts have access to all Redis commands and can perform conditional logic, loops, and calculations server-side.

Loading scripts with SCRIPT LOAD returns a SHA hash that you use with EVALSHA for subsequent calls. This avoids sending the full script text on every execution, reducing network overhead for frequently-used scripts.

// 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 ideal for session storage because it provides fast access, automatic expiration via TTL, and persistence options. Sessions stored in Redis are shared across all application instances, enabling horizontal scaling without sticky sessions.

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 implementation uses Redis INCR with TTL. The first request in a window creates the key and sets its expiration. Subsequent requests increment the counter until it exceeds the limit or the window expires.

async function fixedWindowRateLimit(userId, limit = 100, windowSeconds = 60) {
  const window = Math.floor(Date.now() / 1000 / windowSeconds);
  const key = `ratelimit:${userId}:${window}`;
 
  const current = await redis.incr(key);
  if (current === 1) {
    await redis.expire(key, windowSeconds);
  }
 
  return {
    allowed: current <= limit,
    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 approach balances accuracy with resource usage. It's more accurate than fixed window without the memory overhead of tracking individual request timestamps. The trade-off is slightly more complex calculation logic.

async function slidingWindowRateLimit(userId, limit = 100, windowSeconds = 60) {
  const now = Date.now();
  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, 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

As data and traffic grow, single Redis instance won't suffice.

How does Redis replication work?

Redis replication creates copies of data on replica nodes that follow a master. The master handles all writes, and replicas asynchronously receive updates to stay synchronized. This provides read scaling by distributing read queries across replicas and improves availability by having backup copies of data.

Replication is asynchronous by default, meaning replicas may lag slightly behind the master. For most use cases this is acceptable, but applications requiring strong consistency must read from the master.

flowchart TB
    MASTER["Master<br/><i>← All 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', // Read from replicas
  preferredSlaves: [
    { ip: 'replica1', port: 6379, prio: 1 },
    { ip: 'replica2', port: 6379, prio: 2 }
  ]
});

What is Redis Sentinel and how does it work?

Redis Sentinel is a high-availability solution that monitors Redis master and replica nodes, automatically promoting a replica to master if the current master fails. Sentinel nodes form a distributed system that agrees on failures through consensus, preventing split-brain scenarios.

Sentinel provides three key capabilities: monitoring to check that instances are working correctly, notification to alert administrators of failures, and automatic failover to promote replicas without manual intervention. Clients connect through Sentinel to discover the current master.

flowchart TB
    subgraph SENTINEL["Sentinel Cluster"]
        S1["Sentinel1"]
        S2["Sentinel2"]
        S3["Sentinel3"]
    end
 
    MASTER["Master"]
    REPLICA1["Replica"]
    REPLICA2["Replica"]
 
    S1 -->|"Monitor"| MASTER
    S2 -->|"Monitor"| MASTER
    S3 -->|"Monitor"| MASTER
 
    MASTER --> REPLICA1
    MASTER --> REPLICA2

Sentinel provides:

  • Monitoring master and replicas
  • Automatic failover if master fails
  • Configuration provider for clients

What is Redis Cluster and how does it work?

Redis Cluster provides horizontal scaling by sharding data across multiple master nodes. Each master handles a portion of the 16,384 hash slots that partition the key space. Keys are assigned to slots using CRC16 hashing, distributing data evenly across the cluster.

Each master can have replicas for high availability within its shard. If a master fails, its replica is promoted automatically. The cluster handles resharding when adding or removing nodes, redistributing slots across the new topology.

flowchart TB
    subgraph CLUSTER["Redis Cluster"]
        subgraph SHARD1["Shard 1"]
            M1["Master 1<br/>Slots 0-5460"]
            R1["Replica 1"]
            M1 --> R1
        end
        subgraph SHARD2["Shard 2"]
            M2["Master 2<br/>Slots 5461-10922"]
            R2["Replica 2"]
            M2 --> R2
        end
        subgraph SHARD3["Shard 3"]
            M3["Master 3<br/>Slots 10923-16383"]
            R3["Replica 3"]
            M3 --> R3
        end
    end

Key concepts:

  • 16384 hash slots distributed across masters
  • Keys hashed to slots: CRC16(key) % 16384
  • Each master handles a range 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 slot

What is the difference between Redis Sentinel and Redis Cluster?

Sentinel and Cluster solve different problems. Sentinel provides high availability for a single-master setup—all data lives on one master, and Sentinel ensures failover if that master fails. Cluster provides horizontal scaling by distributing data across multiple masters.

Choose Sentinel when your data fits in a single node's memory and you need automatic failover. Choose Cluster when you need to scale beyond single-node capacity or want to distribute load across multiple masters.

AspectSentinelCluster
PurposeHigh availabilityHorizontal scaling
Data distributionAll data on masterSharded across masters
Max data sizeSingle node memoryCombined node memory
Automatic failoverYesYes
Multi-key operationsAll keys accessibleOnly 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:

  • Locking for guaranteed single regeneration
  • Probabilistic early refresh for high traffic
  • Background refresh for critical data

Scaling:

  • Read scaling → Replication
  • High availability → Sentinel
  • Data scaling → Cluster

Ready to ace your interview?

Get 550+ interview questions with detailed answers in our comprehensive PDF guides.

View PDF Guides