May 15, 2026·8 min·By

Redis Caching in Node.js: Patterns That Actually Work in Production

RediscachingNode.jsperformancebackend

Adding Redis caching naively creates new problems: stale data, stampedes, and cache invalidation bugs. Here's how to do it correctly.

Setup

import { createClient } from 'redis';

const redis = createClient({
    url: process.env.REDIS_URL,
    socket: {
        reconnectStrategy: (retries) => Math.min(retries * 50, 2000)
    }
});

redis.on('error', (err) => console.error('Redis error:', err));
await redis.connect();

Cache-Aside Pattern (Most Common)

async function getUserById(id: string) {
    const cacheKey = `user:${id}`;

    // 1. Check cache
    const cached = await redis.get(cacheKey);
    if (cached) return JSON.parse(cached);

    // 2. Miss: fetch from DB
    const user = await db.query('SELECT * FROM users WHERE id = $1', [id]);
    if (!user) return null;

    // 3. Store in cache with TTL
    await redis.setEx(cacheKey, 3600, JSON.stringify(user)); // 1 hour

    return user;
}

Cache Stampede Prevention

When a cached item expires, many concurrent requests hit the DB simultaneously. Fix this with probabilistic early expiration or a lock:

async function getWithLock<T>(
    key: string,
    fetch: () => Promise<T>,
    ttl: number = 300
): Promise<T> {
    const cached = await redis.get(key);
    if (cached) return JSON.parse(cached);

    const lockKey = `lock:${key}`;
    const lockAcquired = await redis.set(lockKey, '1', {
        NX: true,  // Only set if not exists
        EX: 10     // Auto-release after 10s
    });

    if (!lockAcquired) {
        // Another process is fetching -- wait and retry
        await new Promise(r => setTimeout(r, 100));
        return getWithLock(key, fetch, ttl);
    }

    try {
        const value = await fetch();
        await redis.setEx(key, ttl, JSON.stringify(value));
        return value;
    } finally {
        await redis.del(lockKey);
    }
}

Write-Through Cache

When you write to DB, immediately update the cache:

async function updateUser(id: string, data: Partial<User>) {
    // Update database
    const updated = await db.query(
        'UPDATE users SET ... WHERE id = $1 RETURNING *',
        [id, ...Object.values(data)]
    );

    // Immediately update cache
    const cacheKey = `user:${id}`;
    await redis.setEx(cacheKey, 3600, JSON.stringify(updated));

    return updated;
}

Cache Invalidation

For complex invalidation, use tags:

// Tag-based invalidation
async function cacheWithTags(key: string, value: any, tags: string[], ttl: number) {
    await redis.setEx(key, ttl, JSON.stringify(value));

    // Store key in each tag's set
    for (const tag of tags) {
        await redis.sAdd(`tag:${tag}`, key);
        await redis.expire(`tag:${tag}`, ttl + 60);
    }
}

async function invalidateTag(tag: string) {
    const keys = await redis.sMembers(`tag:${tag}`);
    if (keys.length) await redis.del([...keys, `tag:${tag}`]);
}

// Usage
await cacheWithTags(`user:${id}`, user, [`user:${id}`, 'users'], 3600);
// Invalidate when user's account changes
await invalidateTag(`user:${id}`);

Rate Limiting

async function rateLimit(identifier: string, limit: number, windowSec: number): Promise<boolean> {
    const key = `ratelimit:${identifier}`;
    const current = await redis.incr(key);

    if (current === 1) {
        await redis.expire(key, windowSec);
    }

    return current <= limit;
}

// Middleware
app.use(async (req, res, next) => {
    const allowed = await rateLimit(
        req.ip,
        100,   // 100 requests
        60     // per 60 seconds
    );

    if (!allowed) {
        return res.status(429).json({ error: 'Rate limit exceeded' });
    }
    next();
});

Session Storage

import session from 'express-session';
import { createClient } from 'redis';
import connectRedis from 'connect-redis';

const RedisStore = connectRedis(session);

app.use(session({
    store: new RedisStore({ client: redis }),
    secret: process.env.SESSION_SECRET!,
    resave: false,
    saveUninitialized: false,
    cookie: {
        secure: process.env.NODE_ENV === 'production',
        httpOnly: true,
        maxAge: 24 * 60 * 60 * 1000  // 24 hours
    }
}));

Monitoring

// Check Redis memory usage
const info = await redis.info('memory');
const memUsed = info.match(/used_memory_human:(\S+)/)?.[1];
const maxMem = info.match(/maxmemory_human:(\S+)/)?.[1];
console.log(`Redis memory: ${memUsed} / ${maxMem}`);

// Monitor hit rate
const stats = await redis.info('stats');
const hits = stats.match(/keyspace_hits:(\d+)/)?.[1];
const misses = stats.match(/keyspace_misses:(\d+)/)?.[1];
const hitRate = Number(hits) / (Number(hits) + Number(misses));
console.log(`Cache hit rate: ${(hitRate * 100).toFixed(1)}%`);

Target hit rate above 80%. Below that, you're paying for Redis without getting the benefit. Check if your TTLs are too short or your cache keys are too granular.

K
Founder & Technical Lead, Innovibe

Building software for 15+ years. Passionate about AI, system design, and shipping things that work.

Frequently asked questions

How do I handle database migrations safely in production?+

Always run migrations before deploying new code (never after). Use additive-only migrations — add columns with defaults, never rename or drop in the same release. Blue-green deployments make this much safer.

When should I add a Redis cache vs just querying Postgres?+

Add Redis when the same query runs more than ~10 times per second, when query time exceeds 10ms and latency matters, or when you need pub/sub. Don't add it preemptively — optimise the query first.

Does Innovibe build this kind of thing for clients?+

Yes — this is exactly what we do day-to-day for clients across BC and Canada. If you'd rather have us build and maintain it than implement it yourself, reach out.

How do I decide whether to build this in-house or hire an agency?+

Build in-house if your team has the skills and bandwidth and this is core to your product. Hire out if it's infrastructure, if speed matters, or if the expertise gap would take months to close. We're biased, obviously — but we'll tell you honestly when in-house makes more sense.

What tech stack does Innovibe use for projects like this?+

Next.js + TypeScript on the frontend, Node.js or Go on the backend, Postgres for the primary data store, and GCP (Cloud Run, BigQuery, Pub/Sub) for infrastructure. We pick tools that are boring in the best way — proven, well-documented, and easy to hire for.

Building something with AI?

We scope and ship AI features quickly. Let's talk.

Start a Conversation