What is Redis? The In-Memory Database I Used for Years Before I Understood It

Redis is an in-memory data structure store used as a cache, message broker, session store, rate limiter, and real-time leaderboard engine. I dropped it into production as a cache before I understood what it actually was. Here is what it is, how it works, and what it does well.

I used Redis for about a year before I genuinely understood what it was. I knew it was "a cache." I knew it was fast. I knew you stored key-value pairs in it and retrieved them quickly. I set a cache key, put some JSON in it, fetched it back, and called that a Redis integration.

That is like saying you know what a car is because you have sat in one. Technically true. Completely missing the point.

Redis is an in-memory data structure store. The "data structure" part is where it becomes interesting and where most introductions fail you. It is not just a key-value store where every value is a string. Redis has native support for strings, lists, sets, sorted sets, hashes, streams, bitmaps, HyperLogLog sketches, and geospatial indexes. Each data structure comes with operations designed for it. You do not just put data in and take data out. You perform computations on the data while it lives in Redis, at sub-millisecond speed, without moving it to your application.

That distinction changes what you reach for Redis to do. Session storage. Rate limiting with atomic increments. Real-time leaderboards with sorted sets. Pub/sub message brokering between services. Job queues that preserve ordering. Approximate unique visitor counting with HyperLogLog. Geospatial radius queries. Distributed locks that expire automatically.

All of this is Redis. Not a cache with some extra features. A data structure server that happens to be extremely fast because everything lives in memory.

The Origin

Salvatore Sanfilippo (antirez) created Redis in 2009 while building a real-time analytics system for an Italian startup. He hit performance limits with PostgreSQL for storing real-time page view counts and needed something that could handle the write throughput. He started writing his own tool in C, open-sourced it, and Redis became one of the fastest-adopted infrastructure projects in history.

Redis Labs (now Redis Inc.) has supported the project commercially since 2011. The core Redis codebase remained BSD-licensed until 2024, when Redis Inc. changed the license to a dual RSALv2/SSPLv1 license, which restricts cloud providers from offering Redis as a managed service without a commercial agreement. This created the Valkey fork (backed by the Linux Foundation, AWS, Google, and others) as the community continuation of the BSD-licensed codebase. Both are functionally compatible. The licensing situation is worth knowing but does not affect how you use Redis.

Why In-Memory Matters

Every database operation eventually touches storage. Traditional databases store data on disk. Reads require fetching pages from disk into memory. Writes require persisting pages to disk. Even with fast NVMe SSDs, disk I/O introduces latency measured in tenths of milliseconds to milliseconds.

Redis keeps its entire dataset in RAM. A Redis GET command completes in under a millisecond. On the same machine, 100,000 GET operations per second is typical. On well-provisioned hardware with pipelining, Redis handles over a million operations per second.

This is not a small difference. For operations where you need sub-millisecond response times, disk-based storage is not an option. Session lookups that happen on every authenticated HTTP request. Rate limit counters that must be checked and incremented atomically before serving a response. Real-time game leaderboards updated thousands of times per second. These patterns require RAM.

The tradeoff: RAM is expensive. Your entire working dataset must fit in the memory you allocate to Redis. A dataset larger than available RAM must either be managed with eviction policies (old data gets evicted when memory fills) or sharded across multiple Redis instances.

Redis addresses the durability concern through two persistence mechanisms. RDB snapshots write a point-in-time copy of the dataset to disk at configurable intervals. AOF (Append-Only File) logs every write operation to disk in real time. You can run both, neither, or either. The tradeoff is startup time, disk I/O overhead, and data loss on crash: RDB snapshots risk losing everything since the last snapshot; AOF risks losing the most recent operations if the OS buffer was not flushed.

For caching, persistence is often irrelevant. If Redis restarts, the cache is cold and repopulates from the database. For session storage or message queues where data cannot be lost, run AOF with appendfsync always to flush every write to disk.

Core Data Structures

Strings

Strings are the basic type. Any binary-safe value up to 512MB. The name is misleading because strings in Redis store integers, floats, serialized JSON, protobuf payloads, or actual text — the operations differ based on what you are storing.

SET user:1000:name "Traven"
GET user:1000:name
 
SET product:42:price "79.99"
INCRBYFLOAT product:42:price 5.00
GET product:42:price
 
SET session:token:abc123 '{"user_id":1000,"role":"admin"}' EX 3600
TTL session:token:abc123
 
SETNX lock:payment:order_789 "processing"
EXPIRE lock:payment:order_789 30

SET key value EX seconds sets the key with an expiry. TTL key returns the remaining seconds. SETNX (Set if Not eXists) returns 1 if the key was set and 0 if it already existed — this is the primitive for distributed locking. INCR and INCRBYFLOAT are atomic increment operations. Two concurrent requests calling INCR rate:user:1000 will each get a unique incremented value. No race condition.

Lists

Lists are ordered sequences of strings, implemented as linked lists. Push and pop from either end in O(1). Access by index is O(n).

LPUSH notifications:user:1000 '{"type":"comment","post_id":42,"from":"alice"}'
LPUSH notifications:user:1000 '{"type":"like","post_id":17,"from":"bob"}'
 
LRANGE notifications:user:1000 0 9
LLEN notifications:user:1000
 
LREM notifications:user:1000 0 '{"type":"like","post_id":17,"from":"bob"}'
 
RPUSH job_queue '{"job":"send_email","to":"[email protected]"}'
BLPOP job_queue 0

LRANGE key 0 -1 returns the entire list. LRANGE key 0 9 returns the first 10 elements. BLPOP blocks until an element is available or the timeout expires, which is how you implement a job worker that waits for work rather than polling.

Lists give you a FIFO queue (RPUSH to add, BLPOP from left to consume) or LIFO stack (LPUSH to add, BLPOP from left to consume). Chat message history, activity feeds, job queues, and undo stacks all map naturally to lists.

Sets

Sets are unordered collections of unique strings. Fast membership tests, intersections, unions, and differences.

SADD product:42:tags "electronics" "headphones" "wireless" "noise-cancelling"
SADD product:99:tags "electronics" "speakers" "wireless" "bluetooth"
 
SMEMBERS product:42:tags
SISMEMBER product:42:tags "wireless"
SCARD product:42:tags
 
SINTER product:42:tags product:99:tags
SUNION product:42:tags product:99:tags
SDIFF product:42:tags product:99:tags
 
SADD online_users user:1000 user:1001 user:1002
SREM online_users user:1001
SRANDMEMBER online_users 3

SINTER returns elements in all specified sets. SUNION returns all elements across all sets. SDIFF returns elements in the first set but not in subsequent sets. For social features (mutual friends, shared interests, common followers), set operations in Redis are often orders of magnitude faster than equivalent SQL JOIN queries.

SRANDMEMBER returns a random member without removing it. SPOP removes and returns a random member. For sampling, lottery mechanics, or randomized content delivery: set operations.

Sorted Sets

Sorted sets are the most powerful Redis data structure. Every member has a floating-point score. Members are sorted by score, ascending. Lookups by member, by score range, or by rank all run in O(log n).

ZADD leaderboard 12450 "player:alice"
ZADD leaderboard 9800  "player:bob"
ZADD leaderboard 15200 "player:carol"
ZADD leaderboard 11100 "player:dave"
 
ZREVRANGE leaderboard 0 9 WITHSCORES
 
ZRANK leaderboard "player:alice"
ZREVRANK leaderboard "player:alice"
ZSCORE leaderboard "player:alice"
 
ZINCRBY leaderboard 500 "player:alice"
 
ZRANGEBYSCORE leaderboard 10000 20000 WITHSCORES
 
ZADD rate_limit:user:1000 1704067200 "req_001"
ZADD rate_limit:user:1000 1704067201 "req_002"
ZREMRANGEBYSCORE rate_limit:user:1000 0 1704063600
ZCARD rate_limit:user:1000

The leaderboard is the canonical sorted set use case. ZREVRANGE returns members from highest to lowest score. ZREVRANK returns a member's rank (0-indexed from the top). ZINCRBY atomically adds to a score. A globally ranked leaderboard for a game with millions of players: sorted set.

The rate limiting pattern at the bottom stores request timestamps as scores, member names as unique request identifiers. ZREMRANGEBYSCORE removes requests older than the window. ZCARD counts requests in the window. If the count exceeds the limit, reject the request. This sliding window rate limiter is accurate and atomic in Redis.

Hashes

Hashes store field-value pairs under a single key. One hash per object.

HSET user:1000 username "traven" email "[email protected]" level "5" xp "12450"
HGET user:1000 username
HMGET user:1000 username email level
HGETALL user:1000
HINCRBY user:1000 xp 100
HDEL user:1000 level
HEXISTS user:1000 email
HLEN user:1000

Hashes are more memory-efficient than storing each field as a separate string key when all fields belong to the same entity. HSET user:1000 field value is more efficient than SET user:1000:field value for objects with many fields. HINCRBY atomically increments a numeric field — game stats, counters, balances — without fetching and re-storing the entire object.

Streams

Streams are an append-only log data structure, similar to Kafka topics. Producers append messages. Consumer groups read messages, acknowledge them, and allow parallel processing with tracking of which messages have been delivered.

import redis
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
 
r.xadd('events:orders', {
    'order_id': '1001',
    'user_id': '500',
    'total': '79.99',
    'status': 'created'
})
 
r.xadd('events:orders', {
    'order_id': '1002',
    'user_id': '501',
    'total': '149.99',
    'status': 'created'
})
 
r.xgroup_create('events:orders', 'order-processor', id='0', mkstream=True)
 
messages = r.xreadgroup('order-processor', 'worker-1', {'events:orders': '>'}, count=10, block=5000)
for stream, entries in messages:
    for msg_id, data in entries:
        print(f"Processing order {data['order_id']}: ${data['total']}")
        r.xack('events:orders', 'order-processor', msg_id)

The > consumer group cursor means "give me messages not yet delivered to any consumer in this group." XACK marks messages as processed. If a worker crashes before acknowledging, the messages remain in a pending state and can be reclaimed by another worker. This is exactly what message queues like RabbitMQ provide, but built into Redis.

Streams suit event sourcing, audit logs, activity feeds, and microservice event buses where you want durable, ordered, consumer-group-aware message delivery without operating a separate message broker.

Production Patterns

Caching with Cache-Aside

Cache-aside is the pattern where your application checks the cache before the database. On a miss, it queries the database and populates the cache.

import redis
import json
import psycopg2
from typing import Optional
 
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
 
def get_product(product_id: int) -> Optional[dict]:
    cache_key = f'product:{product_id}'
    cached = r.get(cache_key)
 
    if cached:
        return json.loads(cached)
 
    conn = psycopg2.connect("dbname=myapp user=postgres")
    cursor = conn.cursor()
    cursor.execute(
        "SELECT id, name, price, stock, status FROM products WHERE id = %s AND status = 'active'",
        (product_id,)
    )
    row = cursor.fetchone()
    cursor.close()
    conn.close()
 
    if not row:
        r.set(cache_key, 'null', ex=60)
        return None
 
    product = {
        'id': row[0], 'name': row[1],
        'price': float(row[2]), 'stock': row[3], 'status': row[4]
    }
    r.set(cache_key, json.dumps(product), ex=300)
    return product
 
def invalidate_product_cache(product_id: int) -> None:
    r.delete(f'product:{product_id}')
    r.delete('products:featured')
    r.delete('products:list:active')

Caching null for missing records (negative caching) prevents cache stampedes where thousands of requests for a non-existent resource all hit the database simultaneously. A 60-second TTL on null entries protects the database without serving stale "not found" responses for long.

invalidate_product_cache deletes all cache keys that might contain stale product data whenever a product is updated. The products:list:* keys cover paginated list caches. If you forget to invalidate a cache key on write, users see stale data until the TTL expires.

Distributed Rate Limiting

import redis
import time
from typing import Tuple
 
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
 
def check_rate_limit_sliding_window(
    identifier: str,
    limit: int,
    window_seconds: int
) -> Tuple[bool, int, int]:
    now = time.time()
    window_start = now - window_seconds
    key = f'rate_limit:{identifier}'
 
    pipe = r.pipeline()
    pipe.zremrangebyscore(key, 0, window_start)
    pipe.zadd(key, {str(now): now})
    pipe.zcard(key)
    pipe.expire(key, window_seconds + 1)
    results = pipe.execute()
 
    current_count = results[2]
    allowed = current_count <= limit
    reset_at = int(now) + window_seconds
 
    return allowed, current_count, reset_at
 
def check_rate_limit_token_bucket(
    identifier: str,
    capacity: int,
    refill_rate: float
) -> bool:
    key = f'token_bucket:{identifier}'
    now = time.time()
 
    lua_script = """
    local key = KEYS[1]
    local capacity = tonumber(ARGV[1])
    local refill_rate = tonumber(ARGV[2])
    local now = tonumber(ARGV[3])
 
    local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
    local tokens = tonumber(bucket[1]) or capacity
    local last_refill = tonumber(bucket[2]) or now
 
    local elapsed = now - last_refill
    local new_tokens = math.min(capacity, tokens + elapsed * refill_rate)
 
    if new_tokens < 1 then
        redis.call('HMSET', key, 'tokens', new_tokens, 'last_refill', now)
        redis.call('EXPIRE', key, 3600)
        return 0
    end
 
    new_tokens = new_tokens - 1
    redis.call('HMSET', key, 'tokens', new_tokens, 'last_refill', now)
    redis.call('EXPIRE', key, 3600)
    return 1
    """
 
    result = r.eval(lua_script, 1, key, capacity, refill_rate, now)
    return bool(result)

The sliding window rate limiter uses a sorted set. Scores are timestamps. ZREMRANGEBYSCORE removes timestamps older than the window. ZADD adds the current request. ZCARD counts requests in the window. The pipeline executes all four commands atomically in a single round-trip.

The Lua script for token bucket runs server-side inside Redis, making the entire read-modify-write operation atomic without a transaction or lock. redis.call() inside Lua runs Redis commands atomically. Lua scripts in Redis are a powerful pattern for complex atomic operations that cannot be expressed with simple pipeline batching.

Session Storage

import redis
import json
import uuid
import time
from typing import Optional
 
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
 
SESSION_TTL = 3600
REFRESH_THRESHOLD = 900
 
def create_session(user_id: int, user_data: dict) -> str:
    session_id = str(uuid.uuid4())
    session_key = f'session:{session_id}'
 
    session_data = {
        'user_id': user_id,
        'created_at': int(time.time()),
        'last_active': int(time.time()),
        **user_data
    }
 
    pipe = r.pipeline()
    pipe.hset(session_key, mapping={k: json.dumps(v) if isinstance(v, (dict, list)) else str(v)
                                      for k, v in session_data.items()})
    pipe.expire(session_key, SESSION_TTL)
    pipe.sadd(f'user:{user_id}:sessions', session_id)
    pipe.execute()
 
    return session_id
 
def get_session(session_id: str) -> Optional[dict]:
    session_key = f'session:{session_id}'
    data = r.hgetall(session_key)
 
    if not data:
        return None
 
    ttl = r.ttl(session_key)
    if ttl > 0 and ttl < REFRESH_THRESHOLD:
        r.expire(session_key, SESSION_TTL)
 
    return {k: (json.loads(v) if v.startswith(('{', '[')) else v)
            for k, v in data.items()}
 
def invalidate_all_user_sessions(user_id: int) -> int:
    sessions_key = f'user:{user_id}:sessions'
    session_ids = r.smembers(sessions_key)
 
    if not session_ids:
        return 0
 
    pipe = r.pipeline()
    for session_id in session_ids:
        pipe.delete(f'session:{session_id}')
    pipe.delete(sessions_key)
    pipe.execute()
 
    return len(session_ids)

The session implementation stores session data in a hash (efficient field-level access and updates), tracks all sessions per user in a set (enables "log out all devices"), and refreshes TTL when sessions are accessed within the last 15 minutes (sliding expiry). The sliding expiry keeps active sessions alive without requiring explicit keep-alive calls from clients.

Pub/Sub

import redis
import json
import threading
 
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
publisher = redis.Redis(host='localhost', port=6379, decode_responses=True)
 
def publish_event(channel: str, event_type: str, data: dict) -> int:
    message = json.dumps({
        'event_type': event_type,
        'data': data,
        'timestamp': int(__import__('time').time())
    })
    return publisher.publish(channel, message)
 
def subscribe_to_channel(channel: str) -> None:
    pubsub = r.pubsub()
    pubsub.subscribe(channel)
 
    for message in pubsub.listen():
        if message['type'] == 'message':
            data = json.loads(message['data'])
            print(f"[{channel}] {data['event_type']}: {data['data']}")
 
subscriber_thread = threading.Thread(
    target=subscribe_to_channel,
    args=('order_events',),
    daemon=True
)
subscriber_thread.start()
 
publish_event('order_events', 'order.created', {'order_id': 1001, 'total': 79.99})
publish_event('order_events', 'order.shipped', {'order_id': 995, 'tracking': 'UPS123'})

Redis pub/sub is fire-and-forget. Publishers send messages to a channel. Subscribers receive them. Messages published while a subscriber is disconnected are lost. For real-time notifications that are acceptable to miss on disconnect (live dashboards, notification badges, collaborative cursors), pub/sub is appropriate. For messages that must be delivered reliably (order status changes, payment events), use Redis Streams or a proper message queue.

Configuration for Production

# /etc/redis/redis.conf
 
maxmemory 4gb
maxmemory-policy allkeys-lru
 
save 3600 1
save 300 100
save 60 10000
 
appendonly yes
appendfsync everysec
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb
 
requirepass your_strong_password_here
bind 127.0.0.1
protected-mode yes
 
tcp-keepalive 300
timeout 0
hz 10
aof-use-rdb-preamble yes
 
slowlog-log-slower-than 10000
slowlog-max-len 128

maxmemory-policy allkeys-lru evicts the least recently used key when memory is full. This is the right policy for a pure cache where all data can be regenerated from the database. For session storage or queues where data must not be evicted, use noeviction and monitor memory usage to ensure you never hit the limit.

appendfsync everysec flushes the AOF to disk every second. You risk losing up to one second of writes on a crash, but the performance impact is minimal. appendfsync always guarantees no data loss at the cost of significant write overhead.

Redis vs Memcached

The most common comparison. Memcached is older (2003), simpler, and does one thing: string key-value caching. Redis does everything Memcached does plus sorted sets, streams, pub/sub, Lua scripting, replication, clustering, and persistence.

For pure HTML fragment or serialized object caching where you never need any of Redis's advanced data structures: Memcached is marginally more memory-efficient and faster for simple GET/SET workloads. For any project where you might eventually need a leaderboard, a job queue, rate limiting, or pub/sub: start with Redis. The overhead of choosing Redis when you only need a cache is near zero. The overhead of migrating from Memcached to Redis when you need sorted sets is real work.

Redis Cluster and High Availability

Redis Sentinel provides high availability for a single-shard deployment: a primary with one or more replicas, with Sentinel processes monitoring health and performing automatic failover when the primary goes down.

Redis Cluster provides horizontal scaling through automatic sharding. The keyspace is divided into 16,384 slots. You assign slots to nodes. Keys hash to slots deterministically. Reads and writes go to the node owning the relevant slot. Cluster handles resharding when you add or remove nodes.

Managed Redis services (AWS ElastiCache, Redis Cloud, Upstash, Render, Railway) handle replication, failover, backups, and patching. For most applications, the operational overhead of running your own Redis cluster is not worth the cost savings. Managed services are worth the price.

Redis with TypeScript and Node.js

The ioredis library is the production-grade Redis client for Node.js. It supports pipelining, Lua scripting, cluster mode, sentinel, and connection pooling automatically.

import Redis from 'ioredis';
 
const redis = new Redis({
    host:               process.env.REDIS_HOST ?? 'localhost',
    port:               Number(process.env.REDIS_PORT ?? 6379),
    password:           process.env.REDIS_PASSWORD,
    db:                 0,
    maxRetriesPerRequest: 3,
    retryStrategy(times) {
        const delay = Math.min(times * 50, 2000);
        return delay;
    },
    lazyConnect:        true,
    enableReadyCheck:   true,
    keepAlive:          30000,
    connectTimeout:     10000,
    commandTimeout:     5000,
});
 
redis.on('error', (err) => console.error('Redis error:', err));
redis.on('connect', () => console.log('Redis connected'));
redis.on('reconnecting', () => console.log('Redis reconnecting'));
 
export { redis };

retryStrategy returns the number of milliseconds to wait before the next retry attempt. Exponential backoff with a cap prevents thundering herd reconnects. lazyConnect: true does not actually connect until the first command, giving you a chance to handle connection failures at startup rather than at import time.

import { redis } from './redis';
 
interface CacheOptions {
    ttl?: number;
    prefix?: string;
}
 
class CacheService {
    private prefix: string;
    private defaultTTL: number;
 
    constructor(prefix: string, defaultTTL = 300) {
        this.prefix  = prefix;
        this.defaultTTL = defaultTTL;
    }
 
    private key(id: string | number): string {
        return `${this.prefix}:${id}`;
    }
 
    async get<T>(id: string | number): Promise<T | null> {
        const raw = await redis.get(this.key(id));
        if (!raw) return null;
        if (raw === '__null__') return null;
        try {
            return JSON.parse(raw) as T;
        } catch {
            return raw as unknown as T;
        }
    }
 
    async set<T>(id: string | number, value: T | null, ttl = this.defaultTTL): Promise<void> {
        const serialized = value === null ? '__null__' : JSON.stringify(value);
        if (ttl > 0) {
            await redis.set(this.key(id), serialized, 'EX', ttl);
        } else {
            await redis.set(this.key(id), serialized);
        }
    }
 
    async delete(id: string | number): Promise<void> {
        await redis.del(this.key(id));
    }
 
    async deletePattern(pattern: string): Promise<number> {
        const keys = await redis.keys(`${this.prefix}:${pattern}`);
        if (!keys.length) return 0;
        const pipeline = redis.pipeline();
        keys.forEach(k => pipeline.del(k));
        await pipeline.exec();
        return keys.length;
    }
 
    async getOrSet<T>(
        id: string | number,
        fetcher: () => Promise<T | null>,
        ttl = this.defaultTTL
    ): Promise<T | null> {
        const cached = await this.get<T>(id);
        if (cached !== null) return cached;
 
        const value = await fetcher();
        await this.set(id, value, ttl);
        return value;
    }
 
    async mget<T>(ids: (string | number)[]): Promise<(T | null)[]> {
        if (!ids.length) return [];
        const keys  = ids.map(id => this.key(id));
        const raws  = await redis.mget(...keys);
        return raws.map(raw => {
            if (!raw || raw === '__null__') return null;
            try { return JSON.parse(raw) as T; } catch { return null; }
        });
    }
}
 
export const productCache  = new CacheService('product', 300);
export const userCache     = new CacheService('user', 600);
export const sessionCache  = new CacheService('session', 3600);

__null__ sentinel prevents negative cache misses from being confused with real cache misses. Without it, caching null and finding a cache miss both return null from your function, so you cannot tell whether to re-fetch from the database. The sentinel makes the distinction explicit.

deletePattern using KEYS is appropriate for low-traffic applications. In high-traffic production environments, KEYS blocks the Redis server while it scans. Use SCAN with cursor iteration for production:

async deletePatternSafe(pattern: string): Promise<number> {
    let cursor = '0';
    let deleted = 0;
    const fullPattern = `${this.prefix}:${pattern}`;
 
    do {
        const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', fullPattern, 'COUNT', 100);
        cursor = nextCursor;
        if (keys.length) {
            const pipeline = redis.pipeline();
            keys.forEach(k => pipeline.del(k));
            await pipeline.exec();
            deleted += keys.length;
        }
    } while (cursor !== '0');
 
    return deleted;
}

SCAN iterates the keyspace in chunks without blocking. The COUNT hint suggests how many keys to return per iteration, but Redis is not guaranteed to honor it exactly. The loop continues until cursor returns to '0', indicating a complete scan.

Leaderboard Implementation

interface LeaderboardEntry {
    member:   string;
    score:    number;
    rank:     number;
}
 
class Leaderboard {
    private key: string;
 
    constructor(name: string) {
        this.key = `leaderboard:${name}`;
    }
 
    async addScore(playerId: string, score: number): Promise<void> {
        await redis.zadd(this.key, score, playerId);
    }
 
    async incrementScore(playerId: string, amount: number): Promise<number> {
        const newScore = await redis.zincrby(this.key, amount, playerId);
        return parseFloat(newScore);
    }
 
    async getTopN(n: number): Promise<LeaderboardEntry[]> {
        const results = await redis.zrevrange(this.key, 0, n - 1, 'WITHSCORES');
        const entries: LeaderboardEntry[] = [];
 
        for (let i = 0; i < results.length; i += 2) {
            const member = results[i]!;
            const score  = parseFloat(results[i + 1]!);
            const rank   = i / 2 + 1;
            entries.push({ member, score, rank });
        }
        return entries;
    }
 
    async getPlayerRank(playerId: string): Promise<LeaderboardEntry | null> {
        const [rank, score] = await Promise.all([
            redis.zrevrank(this.key, playerId),
            redis.zscore(this.key, playerId),
        ]);
 
        if (rank === null || score === null) return null;
        return { member: playerId, score: parseFloat(score), rank: rank + 1 };
    }
 
    async getPagedLeaderboard(page: number, pageSize = 20): Promise<LeaderboardEntry[]> {
        const start = (page - 1) * pageSize;
        const stop  = start + pageSize - 1;
        const results = await redis.zrevrange(this.key, start, stop, 'WITHSCORES');
        const entries: LeaderboardEntry[] = [];
 
        for (let i = 0; i < results.length; i += 2) {
            entries.push({
                member: results[i]!,
                score:  parseFloat(results[i + 1]!),
                rank:   start + i / 2 + 1,
            });
        }
        return entries;
    }
 
    async getPlayerNeighbors(playerId: string, radius = 2): Promise<LeaderboardEntry[]> {
        const rank = await redis.zrevrank(this.key, playerId);
        if (rank === null) return [];
 
        const start = Math.max(0, rank - radius);
        const stop  = rank + radius;
        const results = await redis.zrevrange(this.key, start, stop, 'WITHSCORES');
        const entries: LeaderboardEntry[] = [];
 
        for (let i = 0; i < results.length; i += 2) {
            entries.push({
                member: results[i]!,
                score:  parseFloat(results[i + 1]!),
                rank:   start + i / 2 + 1,
            });
        }
        return entries;
    }
}
 
export const globalLeaderboard = new Leaderboard('global');
export const weeklyLeaderboard = new Leaderboard('weekly');

ZREVRANGE returns members from highest to lowest score (reverse order). Rank 1 is the highest score. ZINCRBY atomically adds to an existing score or creates the member if absent. For a game with millions of active players, this leaderboard handles arbitrary ranks and neighborhood queries in O(log n) time regardless of how many players are in the leaderboard.

Distributed Lock

import crypto from 'crypto';
 
class DistributedLock {
    private readonly lockScript = `
        if redis.call("get", KEYS[1]) == ARGV[1] then
            return redis.call("del", KEYS[1])
        else
            return 0
        end
    `;
 
    async acquire(
        resource:   string,
        ttlMs:      number,
        retries   = 3,
        retryDelay = 100
    ): Promise<string | null> {
        const lockKey   = `lock:${resource}`;
        const lockValue = crypto.randomUUID();
 
        for (let attempt = 0; attempt <= retries; attempt++) {
            const result = await redis.set(lockKey, lockValue, 'PX', ttlMs, 'NX');
            if (result === 'OK') return lockValue;
 
            if (attempt < retries) {
                await new Promise(res => setTimeout(res, retryDelay * (attempt + 1)));
            }
        }
        return null;
    }
 
    async release(resource: string, lockValue: string): Promise<boolean> {
        const lockKey = `lock:${resource}`;
        const result  = await redis.eval(this.lockScript, 1, lockKey, lockValue);
        return result === 1;
    }
 
    async withLock<T>(
        resource:   string,
        ttlMs:      number,
        fn:         () => Promise<T>
    ): Promise<T> {
        const lockValue = await this.acquire(resource, ttlMs);
        if (!lockValue) throw new Error(`Failed to acquire lock on ${resource}`);
 
        try {
            return await fn();
        } finally {
            await this.release(resource, lockValue);
        }
    }
}
 
export const lock = new DistributedLock();
 
async function processPayment(orderId: number): Promise<void> {
    await lock.withLock(`order:${orderId}`, 30_000, async () => {
        console.log(`Processing payment for order ${orderId}`);
        await new Promise(res => setTimeout(res, 1000));
        console.log(`Payment complete for order ${orderId}`);
    });
}

The Lua script for release is critical. Without it, you have a race condition: check if the value matches, then delete. Between the check and the delete, another process could acquire the lock, and your delete would release their lock, not yours. The Lua script runs atomically — the check and delete happen as one indivisible operation.

PX sets TTL in milliseconds. NX sets the key only if it does not exist. SET key value PX ttl NX is an atomic test-and-set that forms the basis of the distributed lock. If two processes both try to acquire the same lock simultaneously, exactly one SET NX succeeds.

Monitoring Redis in Production

redis-cli info server
redis-cli info memory
redis-cli info stats
redis-cli info replication
redis-cli info clients
 
redis-cli info memory | grep used_memory_human
redis-cli info stats | grep keyspace_hits
redis-cli info stats | grep keyspace_misses
 
redis-cli monitor
 
redis-cli --latency
redis-cli --latency-history
redis-cli --latency-dist
 
redis-cli slowlog get 10
redis-cli slowlog reset
redis-cli config set slowlog-log-slower-than 10000

The cache hit rate is the most important Redis health metric. keyspace_hits / (keyspace_hits + keyspace_misses). Below 90% means your cache is cold, your TTLs are too short, or your key strategy needs revisiting. Above 95% is healthy.

MONITOR streams every command Redis receives in real time. Useful for debugging unexpected behavior but produces enormous output on busy servers. Use briefly and disconnect.

SLOWLOG GET 10 shows the 10 slowest commands. Any command taking more than 10ms is a problem. In Redis, everything should complete in under 1ms. If you see slow commands, look for KEYS * (blocks while scanning), large collections being processed in a single command, or network latency.

Eviction Policies Explained

When Redis reaches maxmemory, it must decide which keys to remove. The policy choice depends on your use case:

Policy Behavior Use when
noeviction Returns error on new writes Session store, message queue — losing data is unacceptable
allkeys-lru Evicts least recently used key from all keys General-purpose cache where all keys are equally evictable
volatile-lru Evicts LRU key that has an expiry set Mix of permanent and cached data
allkeys-lfu Evicts least frequently used key Access patterns where some keys are much hotter than others
volatile-ttl Evicts key with shortest TTL first Want to keep recently set data over data about to expire
allkeys-random Evicts random key Not recommended — use LRU or LFU

For a cache where all data is regenerable from the database: allkeys-lru. For session storage where data must survive Redis memory pressure: noeviction plus monitoring to ensure you never actually hit the limit.

What Redis Is Not

Redis is not a primary database for relational data. Joins, complex queries, schema evolution, and data larger than RAM are all problems Redis is not designed to solve. PostgreSQL or MySQL for your application's primary storage; Redis for the fast-path operations on top of it.

Redis is not a replacement for a proper message queue when delivery guarantees matter. Redis pub/sub drops messages for disconnected subscribers. Redis Streams are more reliable but still simpler than dedicated message brokers like RabbitMQ or Apache Kafka for complex routing, dead-letter queues, and guaranteed delivery semantics.

Redis is not a search engine. Full-text search with relevance ranking, facets, and fuzzy matching belong in Elasticsearch or similar dedicated search infrastructure, not in Redis.

Used for what it is actually designed for — fast access to structured in-memory data — Redis is one of the most reliable pieces of infrastructure in the modern backend stack. The question is not whether to use it. The question is understanding what you can do with it once you look past "it's a cache."

Redis Persistence Deep Dive

Understanding Redis persistence prevents data loss surprises in production.

RDB (Redis Database Backup) takes point-in-time snapshots. Redis forks the process, and the child writes the dataset to a temporary .rdb file, then atomically renames it. The parent continues serving requests without interruption. RDB produces compact files and fast restarts, but you lose all writes since the last snapshot on a crash.

AOF (Append-Only File) logs every write operation in a human-readable format. On restart, Redis replays the AOF to reconstruct the dataset. AOF is more durable than RDB but produces larger files and slower restarts. BGREWRITEAOF compacts the AOF by rewriting it as the minimal sequence of commands that produces the current dataset.

Hybrid persistence (aof-use-rdb-preamble yes) writes an RDB snapshot at the start of the AOF file, then appends only the incremental changes. Combines fast restart from the RDB preamble with the durability of AOF.

In production, the recommended setup for durable workloads: enable AOF with appendfsync everysec, enable RDB snapshots as a backup, and store both on a filesystem with redundancy. For pure caching where data loss on restart is acceptable: disable both and configure an appropriate eviction policy.

Redis Replication

A Redis primary can have one or more replicas. Replicas receive a stream of write commands from the primary and apply them to maintain an identical dataset. Reads can be served from replicas, distributing read load. If the primary fails, a replica can be promoted.

redis-cli -h replica-host replicaof primary-host 6379
redis-cli -h replica-host info replication
 
redis-cli -h primary-host debug sleep 0
redis-cli -h replica-host replicaof no one

Replication in Redis is asynchronous by default. The primary acknowledges writes before confirming they are on replicas. Configuring min-replicas-to-write 1 and min-replicas-max-lag 10 forces the primary to require at least one replica to be within 10 seconds of sync before accepting writes, which prevents data loss at the cost of availability during replica lag.

Redis Sentinel runs alongside Redis instances and monitors primary and replica health. When a primary fails, Sentinel detects it, elects a new primary from the available replicas, and reconfigures clients to the new primary through a discovery mechanism. Sentinel provides automatic failover without Cluster's sharding complexity.

Valkey: The Fork You Should Know About

In March 2024, Redis Inc. changed the Redis license from BSD to RSALv2/SSPLv1. The new license restricts cloud providers from offering managed Redis without a commercial agreement with Redis Inc. Within days, the Linux Foundation announced Valkey — a fork of Redis 7.2.4 (the last BSD-licensed version) backed by AWS, Google, Oracle, Ericsson, Snap, and others.

Valkey is wire-compatible with Redis. Existing Redis clients, tools, and Dockerfiles work with Valkey without modification. AWS ElastiCache and MemoryDB began offering Valkey in 2024. For new projects, Valkey is worth considering as a license-unrestricted alternative. For existing Redis deployments, the functionality is identical and migration is straightforward.

The practical takeaway: if you are using a managed Redis service (ElastiCache, Upstash, Redis Cloud), your provider handles this. If you are self-hosting, verify that your Redis version predates the license change or evaluate Valkey for new deployments.