Skip to content

Redis Patterns

Knowing Redis commands isn't the same as knowing Redis patterns Any developer can SET key value and GET key value. The real skill is knowing which data structure to use for which problem and how to avoid the distributed systems pitfalls that turn your "fast cache layer" into a source of stale data, lost updates, and race conditions that only manifest under production load when three microservices try to update the same key simultaneously

Caching patterns

Cache-Aside (lazy loading)

The most common caching pattern: when reading data, check Redis first. If found (cache hit), return it. If not (cache miss), read from the database, store in Redis with a TTL, then return

async function getUser(id) {
    // Try cache first
    const cached = await redis.get(`user:${id}`);

    if (cached) {
        return JSON.parse(cached);  // Cache hit - return fast
    }

    // Cache miss - read from database
    const user = await db.query('SELECT * FROM users WHERE id = $1', [id]);

    if (user) {
        // Store in cache with TTL (3600 seconds = 1 hour)
        await redis.setex(`user:${id}`, 3600, JSON.stringify(user));
    }

    return user;
}

Pros: only caches data that's actually requested, handles cache misses gracefully, simple to implement. Cons: first request after TTL expiry always hits the database (thundering herd problem), stale data until TTL expires

Write-Through

Data is written to cache AND database simultaneously. The cache always has fresh data but writes are slower because both systems must complete

async function updateUser(id, data) {
    // Write to database first (source of truth)
    await db.query('UPDATE users SET username = $1 WHERE id = $2', [data.username, id]);

    // Write to cache
    await redis.setex(`user:${id}`, 3600, JSON.stringify(data));

    return data;
}

Pros: cache always consistent with database. Cons: every write is slower (two operations), cache stores data that may never be read again - wasting memory

Write-Behind (write-back)

Write to Redis immediately, then asynchronously write to the database. Extremely fast writes but risky - if Redis crashes before the async write completes, data is lost

async function logPageView(pageId) {
    // Atomic increment in Redis
    const count = await redis.incr(`pageviews:${pageId}`);

    // Every 100 views, persist to database
    if (count % 100 === 0) {
        // Don't await - fire and forget
        db.query('UPDATE page_stats SET views = $1 WHERE id = $2', [count, pageId])
            .catch(err => console.error('Failed to persist view count', err));
    }

    return count;
}

Pros: extremely fast writes (memory speed). Cons: data loss risk, complexity of background sync, reconciliation logic needed when Redis restarts

Cache invalidation

The hardest problem in computer science (after naming things and off-by-one errors). When data changes in the database, the cache must either update or be deleted

// Invalidation strategy 1: delete on update
async function updateUser(id, data) {
    await db.query('UPDATE users SET username = $1 WHERE id = $2', [data.username, id]);
    await redis.del(`user:${id}`);  // Delete cache - next read will repopulate
    // This is usually better than trying to update the cache with the new data
    // because it avoids consistency issues with concurrent updates
}

// Invalidation strategy 2: publish invalidation event
async function updateUser(id, data) {
    await db.query('UPDATE users SET username = $1 WHERE id = $2', [data.username, id]);
    await redis.publish('cache:invalidate', `user:${id}`);
    // Any service subscribed to 'cache:invalidate' knows to drop this key
    // This is essential in microservice architectures where multiple services cache user data
}

Rate limiting patterns

Sliding Window Counter

Track request counts in a sorted set where each request adds an entry with a timestamp score. Count requests within the time window by removing entries outside the window and checking the remaining count

const WINDOW_SIZE = 60;  // 60 seconds
const MAX_REQUESTS = 100;

async function slidingWindowRateLimit(userId) {
    const key = `ratelimit:${userId}`;
    const now = Date.now();
    const windowStart = now - (WINDOW_SIZE * 1000);

    // Remove old entries outside the window (pipeline for atomicity)
    const multi = redis.multi();
    multi.zremrangebyscore(key, 0, windowStart);
    multi.zadd(key, now, `${now}:${Math.random()}`);  // Unique member per request
    multi.zcard(key);   // Count remaining entries
    multi.expire(key, WINDOW_SIZE * 2);  // Cleanup TTL
    const results = await multi.exec();

    const currentCount = results[2];  // zcard result

    if (currentCount > MAX_REQUESTS) {
        return { allowed: false, retryAfter: calculateWaitTime(results[0]) };
    }

    return { allowed: true, remaining: MAX_REQUESTS - currentCount };
}

Token Bucket

A fixed number of tokens are added to a bucket at a steady rate. Each request consumes a token. If no tokens remain, the request is denied. This is simpler to implement and handles burst traffic better than fixed window

async function tokenBucketRateLimit(userId, maxTokens = 100, refillRate = 10, refillInterval = 1) {
    const key = `tokenbucket:${userId}`;
    const now = Math.floor(Date.now() / 1000);

    // Lua script for atomic token bucket logic
    const script = `
        local key = KEYS[1]
        local maxTokens = tonumber(ARGV[1])
        local refillRate = tonumber(ARGV[2])
        local now = tonumber(ARGV[3])

        local bucket = redis.call('HMGET', key, 'tokens', 'lastRefill')
        local tokens = tonumber(bucket[1]) or maxTokens
        local lastRefill = tonumber(bucket[2]) or now

        local elapsed = now - lastRefill
        local refill = math.floor(elapsed * refillRate)
        if refill > 0 then
            tokens = math.min(maxTokens, tokens + refill)
            lastRefill = now
        end

        if tokens >= 1 then
            tokens = tokens - 1
            redis.call('HMSET', key, 'tokens', tokens, 'lastRefill', lastRefill)
            redis.call('EXPIRE', key, 3600)
            return {1, tokens}
        else
            return {0, tokens}
        end
    `;

    const result = await redis.eval(script, 1, key, maxTokens, refillRate, now);
    return { allowed: result[0] === 1, remainingTokens: result[1] };
}

Distributed locks (Redlock)

When multiple application instances need to ensure only one runs a critical section at a time (cron jobs, resource-intensive computations, payment processing), distributed locks prevent duplicate execution

const LOCK_TTL = 30000;  // 30 seconds
const lockKey = 'lock:payment:processor';

async function acquireLock(resource) {
    const identifier = `${process.env.HOSTNAME}:${Date.now()}:${Math.random()}`;

    // SET NX - only sets if key doesn't exist
    // PX - expiry in milliseconds (auto-release if crash)
    const acquired = await redis.set(resource, identifier, 'NX', 'PX', LOCK_TTL);

    if (!acquired) {
        return null;  // Lock held by another instance
    }

    return identifier;  // Return so we can verify when releasing
}

async function releaseLock(resource, identifier) {
    // Lua script ensures we only delete OUR lock (not someone else's)
    const script = `
        if redis.call('GET', KEYS[1]) == ARGV[1] then
            return redis.call('DEL', KEYS[1])
        else
            return 0
        end
    `;

    await redis.eval(script, 1, resource, identifier);
}

// Usage
async function processPayments() {
    const lock = await acquireLock(lockKey);
    if (!lock) {
        console.log('Payment processor already running on another instance');
        return;
    }

    try {
        await processPendingPayments();
    } finally {
        await releaseLock(lockKey, lock);  // Always release in finally
    }
}

Warning: Redlock has been debated by distributed systems experts (see Martin Kleppmann's critique). For most applications the simple lock above is sufficient. For absolute correctness guarantees (financial transactions), use a consensus-based system like ZooKeeper or etcd

Pub/Sub - real-time messaging

Redis Pub/Sub is fire-and-forget messaging: publishers send messages to channels, subscribers receive them. There's no message persistence - if a subscriber isn't connected, it misses the message

// Publisher
async function notifyUser(userId, event) {
    await redis.publish(`user:${userId}:events`, JSON.stringify(event));
    // Any subscriber listening on this channel receives the event
}

// Subscriber (separate process or service)
const subscriber = redis.duplicate();  // Separate connection for pub/sub
await subscriber.connect();

await subscriber.subscribe('user:100:events', (message) => {
    const event = JSON.parse(message);
    console.log('User event received:', event);
    // Send WebSocket notification, trigger email, update dashboard, etc.
});

Queues with Lists and Streams

Simple queue with Lists

// Producer - add work to queue
async function enqueueEmail(userId, template, data) {
    const job = JSON.stringify({ userId, template, data, createdAt: Date.now() });
    await redis.lpush('queue:email', job);
}

// Consumer - process jobs
async function processEmailQueue() {
    while (true) {
        // BRPOP - blocking right pop (waits for items)
        const result = await redis.brpop('queue:email', 5);  // 5 second timeout
        if (result) {
            const [queue, jobData] = result;
            const job = JSON.parse(jobData);
            await sendEmail(job);
        }
    }
}

Streams - Redis' answer to Kafka

Redis Streams (5.0+) provide persistent messaging with consumer groups, message acknowledgment, and message history - making it suitable for reliable job processing where you can't afford to lose messages

// Producer - add to stream
async function addJob(type, payload) {
    return await redis.xadd(
        'job:stream',
        '*',  // Auto-generate ID (timestamp-sequence)
        'type', type,
        'payload', JSON.stringify(payload),
        'createdAt', Date.now().toString()
    );
}

// Consumer group - reliable processing
async function setupConsumer() {
    // Create consumer group (run once)
    await redis.xgroup('CREATE', 'job:stream', 'email-workers', '$', {
        MKSTREAM: true  // Create stream if doesn't exist
    });

    while (true) {
        // Read new messages
        const results = await redis.xreadgroup(
            'GROUP', 'email-workers', 'worker-1',
            'BLOCK', 2000,  // Block for 2 seconds
            'COUNT', 10,
            'STREAMS', 'job:stream', '>'  // '>' = never-before-read messages
        );

        if (results) {
            for (const [stream, messages] of results) {
                for (const [id, fields] of messages) {
                    try {
                        await processJob(fields);
                        await redis.xack('job:stream', 'email-workers', id);
                    } catch (err) {
                        // Failed to process - message stays in pending list
                        console.error(`Job ${id} failed:`, err);
                    }
                }
            }
        }
    }
}

Session storage

// Store session with user data
async function createSession(userId, userData) {
    const sessionId = crypto.randomUUID();
    await redis.hset(`session:${sessionId}`, {
        userId,
        email: userData.email,
        role: userData.role,
        ipAddress: userData.ip,
        userAgent: userData.userAgent,
        createdAt: Date.now().toString()
    });
    await redis.expire(`session:${sessionId}`, 86400);  // 24 hour expiry
    return sessionId;
}

// Validate session
async function getSession(sessionId) {
    const session = await redis.hgetall(`session:${sessionId}`);
    if (!session || Object.keys(session).length === 0) {
        return null;  // Session expired or invalid
    }

    // Touch - extend session TTL on active use
    await redis.expire(`session:${sessionId}`, 86400);
    return session;
}

// Destroy session (logout)
async function destroySession(sessionId) {
    await redis.del(`session:${sessionId}`);
}

prerequisites

db_10_redis_intro.md - you need Redis running and the redis-cli to experiment with the data structures. Understanding the basic types (Strings, Lists, Sets, Sorted Sets, Hashes) is required because every pattern builds on them


next → db_12_redis_security.md