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); // 11Use 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'); // FIFOUse lists for: activity feeds, recent items, simple job queues, capped collections.
Data structure comparison
| Structure | Access pattern | Time complexity | Use case |
|---|---|---|---|
| String | Get/set entire value | O(1) | Simple cached objects, counters |
| Hash | Get/set individual fields | O(1) per field | User profiles, sessions, configs |
| Sorted Set | Range queries by score | O(log n) add, O(log n + m) range | Leaderboards, rate limiting, feeds |
| List | Push/pop from ends, range | O(1) push/pop, O(n) index | Queues, recent activity, capped logs |
| Set | Add/remove/check membership | O(1) | Tags, unique visitors, intersections |
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| Policy | Evicts from | Algorithm | Best for |
|---|---|---|---|
allkeys-lru | All keys | Least Recently Used | General-purpose caching (most common) |
allkeys-lfu | All keys | Least Frequently Used | When access frequency matters more than recency |
volatile-lru | Only keys with TTL | Least Recently Used | Mix of cached data (TTL) and persistent data (no TTL) |
volatile-lfu | Only keys with TTL | Least Frequently Used | Same, but frequency-based |
volatile-ttl | Only keys with TTL | Shortest remaining TTL | Evict data closest to expiring anyway |
noeviction | Nothing | Reject writes | When 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.
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.
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 Aimport 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'
);Practical Redis configuration checklist
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.When deploying Redis for caching in production, here is the minimum you should configure:
- Set
maxmemoryto 75-80% of available RAM (leave room for the OS and forks) - Set
maxmemory-policytoallkeys-lrufor pure caching workloads - Enable persistence (
RDBsnapshots orAOFappend-only file) if you need cache warm-up after restarts - Set up monitoring on memory usage, hit rate, eviction count, and connection count
- Use
SCANinstead ofKEYSfor production key enumeration (KEYS blocks the server)