System Design/
Lesson

Redis is the default choice for application-level caching in most production systems, and for good reason. It is fast (100,000+ operations per second on a single node), it runs in memory, it supports data structures beyond simple key-value pairs, and it has been battle-tested at every scale from startup to Netflix.

But "just use Redis" is not a strategy. The difference between a well-designed Redis layer and a poorly designed one comes down to choosing the right data structures, configuring eviction correctly, and understanding the operational basics.

Redis data structures for caching

Most developers only use Redis strings (GET / SET), which is like buying a Swiss Army knife and only using the bottle opener. Redis has rich data structures, and each one enables caching patterns that would be awkward or impossible with plain strings.

Strings

The simplest structure. Store a serialized value (JSONWhat is json?A text format for exchanging data between systems. It uses key-value pairs and arrays, and every programming language can read and write it. string, number, or binaryWhat is binary?A ready-to-run file produced by the compiler. You can send it to any computer and it just works - no install needed. data) under a key.

// Cache a serialized object
await redis.setex('user:42', 3600, JSON.stringify({ id: 42, name: 'Alice' }));
const user = JSON.parse(await redis.get('user:42'));

// Atomic counter (no need to read-modify-write)
await redis.incr('page:views:/home');         // 1
await redis.incrby('page:views:/home', 10);   // 11

Use strings for: simple cached objects, counters, rate limitingWhat is rate limiting?Restricting how many requests a client can make within a time window. Prevents brute-force attacks and protects your API from being overwhelmed. tokens.

Hashes

A hash is a map of field-value pairs under a single key. It lets you read or write individual fields without deserializing the whole object.

// Store user profile as a hash
await redis.hset('user:42', {
  name: 'Alice',
  email: '[email protected]',
  plan: 'pro',
  loginCount: '157',
});

// Read a single field (no need to fetch the whole object)
const plan = await redis.hget('user:42', 'plan'); // 'pro'

// Update one field without touching the rest
await redis.hincrby('user:42', 'loginCount', 1); // 158

// Get the entire hash
const profile = await redis.hgetall('user:42');
// { name: 'Alice', email: '[email protected]', plan: 'pro', loginCount: '158' }

Use hashes for: objects where you frequently read or update individual fields, user sessions, feature flags, configuration.

Sorted sets

A sorted set stores unique members, each with a numeric score. Members are automatically ordered by score, and you can efficiently query ranges. This is the secret weapon behind leaderboards, feeds, and priority queues.

// Leaderboard: score = points, member = user ID
await redis.zadd('leaderboard', 1500, 'user:42');
await redis.zadd('leaderboard', 2300, 'user:17');
await redis.zadd('leaderboard', 1800, 'user:91');

// Top 10 players (highest scores first)
const top10 = await redis.zrevrange('leaderboard', 0, 9, 'WITHSCORES');
// ['user:17', '2300', 'user:91', '1800', 'user:42', '1500']

// User's rank (0-indexed, highest first)
const rank = await redis.zrevrank('leaderboard', 'user:42'); // 2

// Rate limiter: score = timestamp, member = request ID
const now = Date.now();
const windowStart = now - 60_000; // 1-minute window
await redis.zadd('ratelimit:user:42', now, `req:${now}`);
await redis.zremrangebyscore('ratelimit:user:42', 0, windowStart); // Remove old entries
const count = await redis.zcard('ratelimit:user:42');
if (count > 100) throw new Error('Rate limit exceeded');

Use sorted sets for: leaderboards, time-series data, sliding window rate limiters, priority queues, activity feeds.

Lists

Lists are ordered sequences. You can push to either end and pop from either end, making them natural queues and stacks.

// Recent activity feed (newest first)
await redis.lpush('feed:user:42', JSON.stringify({
  type: 'comment',
  text: 'Great article!',
  timestamp: Date.now(),
}));

// Keep only the 100 most recent items
await redis.ltrim('feed:user:42', 0, 99);

// Get the 20 most recent items
const recent = await redis.lrange('feed:user:42', 0, 19);
const items = recent.map(JSON.parse);

// Simple job queue
await redis.rpush('jobs:email', JSON.stringify({ to: '[email protected]', template: 'welcome' }));
const job = await redis.lpop('jobs:email'); // FIFO

Use lists for: activity feeds, recent items, simple job queues, capped collections.

02

Data structure comparison

StructureAccess patternTime complexityUse case
StringGet/set entire valueO(1)Simple cached objects, counters
HashGet/set individual fieldsO(1) per fieldUser profiles, sessions, configs
Sorted SetRange queries by scoreO(log n) add, O(log n + m) rangeLeaderboards, rate limiting, feeds
ListPush/pop from ends, rangeO(1) push/pop, O(n) indexQueues, recent activity, capped logs
SetAdd/remove/check membershipO(1)Tags, unique visitors, intersections
03

Eviction policies

Redis stores everything in memory. When memory is full and a new write comes in, Redis must decide what to evict. The eviction policy determines that decision.

# redis.conf
maxmemory 4gb
maxmemory-policy allkeys-lru
PolicyEvicts fromAlgorithmBest for
allkeys-lruAll keysLeast Recently UsedGeneral-purpose caching (most common)
allkeys-lfuAll keysLeast Frequently UsedWhen access frequency matters more than recency
volatile-lruOnly keys with TTLLeast Recently UsedMix of cached data (TTL) and persistent data (no TTL)
volatile-lfuOnly keys with TTLLeast Frequently UsedSame, but frequency-based
volatile-ttlOnly keys with TTLShortest remaining TTLEvict data closest to expiring anyway
noevictionNothingReject writesWhen data loss is unacceptable

The default is noeviction, which means Redis will reject writes once memory is full. For caching, you almost always want allkeys-lru, it lets Redis evict the least recently accessed data to make room for new data. This is a safe default because cached data can always be regenerated from the source.

Use volatile-lru if your Redis instance stores both cached data (with TTLs) and persistent data (without TTLs, like sessionWhat is session?A server-side record that tracks a logged-in user. The browser holds only a session ID in a cookie, and the server looks up the full data on each request. tokens or feature flags). The volatile policies will only evict keys that have a TTLWhat is ttl?Time-to-Live - a countdown attached to cached data that automatically expires it after a set number of seconds., leaving your persistent data untouched.

04

Connection pooling

Every Redis operation requires a TCP connection. Opening a new connection per request is expensive (1-3ms handshakeWhat is handshake?The initial exchange between a client and server that establishes a connection and agrees on communication rules before data starts flowing.) and can exhaust your connection limits under load. Connection pooling maintains a set of pre-opened connections that are reused across requests.

import Redis from 'ioredis';

// Single connection (fine for low traffic)
const redis = new Redis({
  host: 'redis.example.com',
  port: 6379,
  password: 'your-password',
  retryStrategy: (times) => Math.min(times * 50, 2000), // Reconnect with backoff
});

// Connection pool using ioredis Cluster or multiple instances
// For most apps, ioredis handles connection reuse internally.
// If you need explicit pooling (e.g., with generic-pool):
import { createPool } from 'generic-pool';

const pool = createPool({
  create: () => new Redis({ host: 'redis.example.com', port: 6379 }),
  destroy: (client) => client.disconnect(),
}, {
  max: 20,    // Maximum 20 connections
  min: 5,     // Keep at least 5 idle connections
});

async function cachedGet(key) {
  const client = await pool.acquire();
  try {
    return await client.get(key);
  } finally {
    pool.release(client);
  }
}

In practice, ioredis handles connection management well with a single instance for most workloads. You need explicit pooling only at very high concurrencyWhat is concurrency?The ability of a program to handle multiple tasks at the same time, like serving thousands of users without slowing down. or when using a connection-limited Redis service.

05

Redis cluster basics

A single Redis node is limited by the memory of one machine. Redis Cluster shards your data across multiple nodes, each responsible for a subset of the key space (hash slots). There are 16,384 hash slots, distributed evenly across nodes.

3-node Redis Cluster:
  Node A: slots 0-5460
  Node B: slots 5461-10922
  Node C: slots 10923-16383

Key "user:42"hash("user:42") mod 16384 → slot 9291 → Node B
Key "user:17"hash("user:17") mod 16384 → slot 3412 → Node A
import Redis from 'ioredis';

// ioredis handles cluster topology automatically
const cluster = new Redis.Cluster([
  { host: 'redis-1.example.com', port: 6379 },
  { host: 'redis-2.example.com', port: 6379 },
  { host: 'redis-3.example.com', port: 6379 },
]);

// Use it exactly like a single Redis instance
await cluster.set('user:42', JSON.stringify(userData));
const user = await cluster.get('user:42');

The main constraint is that multi-key operations (like MGET or transactions) only work when all keys hash to the same slot. You can force keys to the same slot using hash tags:

// These keys will hash to the same slot because of {user:42}
await cluster.set('{user:42}:profile', profileData);
await cluster.set('{user:42}:settings', settingsData);

// Now you can use MGET on both
const [profile, settings] = await cluster.mget(
  '{user:42}:profile',
  '{user:42}:settings'
);
06

Practical Redis configuration checklist

AI pitfall
AI-generated Redis code often uses KEYS * for debugging or key enumeration. In production, KEYS blocks the Redis server while it scans every key, on a Redis instance with millions of keys, this can freeze your entire caching layer for seconds. Always use SCAN instead, which iterates incrementally without blocking.
Good to know
Redis sorted sets are one of the most versatile data structures in any database. Beyond leaderboards, they handle rate limiting (score = timestamp), delayed job scheduling (score = execution time), and priority queues (score = priority). Learning sorted sets well gives you a tool that solves a surprising number of problems.

When deploying Redis for caching in production, here is the minimum you should configure:

  • Set maxmemory to 75-80% of available RAM (leave room for the OS and forks)
  • Set maxmemory-policy to allkeys-lru for pure caching workloads
  • Enable persistence (RDB snapshots or AOF append-only file) if you need cache warm-up after restarts
  • Set up monitoring on memory usage, hit rate, eviction count, and connection count
  • Use SCAN instead of KEYS for production key enumeration (KEYS blocks the server)