ZyVOP Logo
Content That Connects
SeriesCategoriesTags
ZyVOP Logo
Content That Connects

Empowering developers and creators with cutting-edge insights, comprehensive tutorials, and innovative solutions for the digital future.

Content

  • Tags
  • Write Article

Company

  • About Us
  • Contact

Connect

  • Privacy Policy
  • Terms of Service
  • Cookie Policy
  • DMCA Policy
  • Code of Conduct

© 2026 ZyVOP. Crafted with care for the developer community.

Made with ❤️ by the ZyVOP team
All systems operational
HomeAdding Redis to Your Node.js App: Caching, Rate Limiting, and Session Storage
👍1

Adding Redis to Your Node.js App: Caching, Rate Limiting, and Session Storage

Four production patterns with real working code — from API response caching to atomic rate limiting and cross-instance pub/sub

#Redis Node.js#Redis caching Express#Redis rate limiting#ioredis tutorial#session storage Redis#Redis pub/sub Node.js#production Redis setup 2026
Z
ZyVOP

Senior Developer

May 23, 2026
9 min read
13 views
Adding Redis to Your Node.js App: Caching, Rate Limiting, and Session Storage

Most developers reach for Redis when their app starts feeling slow and someone mentions "just add a cache." That is a fine reason to start. But Redis does a lot more than cache API responses, and understanding the other patterns — rate limiting, session storage, pub/sub — changes how you architect backends from the beginning rather than bolting things on later.

This guide covers the four most practical Redis patterns for a Node.js app, with real working code for each. The stack is Node.js, Express, and ioredis — the most capable Redis client for Node.


Setup

Run Redis locally with Docker:

docker run -d \
  --name redis-dev \
  -p 6379:6379 \
  redis:7-alpine

Install the client:

npm install ioredis

Create a shared Redis client — one instance, used everywhere:

// src/lib/redis.js
import Redis from 'ioredis';

const redis = new Redis({
  host: process.env.REDIS_HOST || 'localhost',
  port: parseInt(process.env.REDIS_PORT || '6379'),
  password: process.env.REDIS_PASSWORD || undefined,
  maxRetriesPerRequest: 3,
  retryStrategy(times) {
    // Exponential backoff — wait longer between each retry
    const delay = Math.min(times * 50, 2000);
    return delay;
  },
  // If Redis goes down, fail gracefully rather than crashing
  enableOfflineQueue: false,
});

redis.on('error', (err) => {
  console.error('Redis connection error:', err.message);
});

redis.on('connect', () => {
  console.log('Redis connected');
});

export default redis;

enableOfflineQueue: false is important for production. It means if Redis is temporarily unreachable, commands fail immediately rather than queuing up and flooding when the connection restores.


Pattern 1 — API Response Caching

The most common use case. Your app hits a slow database query or an external API. You cache the result in Redis so the second request (and every request after) takes milliseconds instead of seconds.

// src/middleware/cache.js
import redis from '../lib/redis.js';

/**
 * Cache middleware factory
 * @param {number} ttlSeconds - How long to cache the response
 * @param {function} keyFn - Function to derive the cache key from req
 */
export function cacheMiddleware(ttlSeconds = 60, keyFn = (req) => `cache:${req.originalUrl}`) {
  return async (req, res, next) => {
    const key = keyFn(req);

    try {
      const cached = await redis.get(key);

      if (cached) {
        res.setHeader('X-Cache', 'HIT');
        return res.json(JSON.parse(cached));
      }
    } catch (err) {
      // If Redis is down, just continue to the actual handler
      console.error('Cache read error:', err.message);
    }

    // Intercept the response to cache it before sending
    const originalJson = res.json.bind(res);
    res.json = async (data) => {
      try {
        await redis.setex(key, ttlSeconds, JSON.stringify(data));
      } catch (err) {
        console.error('Cache write error:', err.message);
      }
      res.setHeader('X-Cache', 'MISS');
      return originalJson(data);
    };

    next();
  };
}

Usage in your router:

import { cacheMiddleware } from '../middleware/cache.js';

// Cache all /products responses for 5 minutes
router.get('/products',
  cacheMiddleware(300),
  async (req, res) => {
    const products = await db.query('SELECT * FROM products WHERE active = true');
    res.json(products.rows);
  }
);

// Cache per-user — key includes user ID
router.get('/profile',
  authenticate,
  cacheMiddleware(120, (req) => `cache:profile:${req.user.id}`),
  async (req, res) => {
    const profile = await db.query('SELECT * FROM users WHERE id = $1', [req.user.id]);
    res.json(profile.rows[0]);
  }
);

Cache invalidation — clear a cache entry when data changes:

// After updating a product, invalidate its cache
router.put('/products/:id', authenticate, async (req, res) => {
  await db.query('UPDATE products SET ... WHERE id = $1', [req.params.id]);

  // Invalidate specific key
  await redis.del(`cache:/products/${req.params.id}`);

  // Or use a pattern to clear all product list caches
  const keys = await redis.keys('cache:/products*');
  if (keys.length > 0) {
    await redis.del(...keys);
  }

  res.json({ success: true });
});

A note on redis.keys(): it blocks the Redis server while scanning. Fine for dev, not for production with large key spaces. Use SCAN instead for production pattern-based deletion.


Pattern 2 — Rate Limiting

Rate limiting without Redis falls apart the moment you run more than one server instance. In-memory rate limiters are per-process, so a user can bypass a 100 req/min limit by hitting different instances. Redis makes rate limits work across your entire fleet.

The sliding window counter approach is the most accurate:

// src/middleware/rateLimiter.js
import redis from '../lib/redis.js';

/**
 * Sliding window rate limiter
 * @param {number} maxRequests - Max requests allowed in the window
 * @param {number} windowSeconds - Time window in seconds
 * @param {function} identifierFn - Function to extract identifier from req
 */
export function rateLimiter(
  maxRequests = 100,
  windowSeconds = 60,
  identifierFn = (req) => req.ip
) {
  return async (req, res, next) => {
    const identifier = identifierFn(req);
    const key = `rate:${identifier}`;

    try {
      // Lua script ensures atomicity — increment and set TTL in one operation
      const luaScript = `
        local current = redis.call('INCR', KEYS[1])
        if current == 1 then
          redis.call('EXPIRE', KEYS[1], ARGV[1])
        end
        return current
      `;

      const current = await redis.eval(luaScript, 1, key, windowSeconds);

      // Set headers so clients know their rate limit status
      res.setHeader('X-RateLimit-Limit', maxRequests);
      res.setHeader('X-RateLimit-Remaining', Math.max(0, maxRequests - current));
      res.setHeader('X-RateLimit-Reset', Math.ceil(Date.now() / 1000) + windowSeconds);

      if (current > maxRequests) {
        return res.status(429).json({
          error: 'Too many requests',
          retryAfter: windowSeconds,
        });
      }
    } catch (err) {
      // If Redis is down, fail open — let the request through
      // Better to serve the request than to lock out all users because Redis is restarting
      console.error('Rate limiter error:', err.message);
    }

    next();
  };
}

Usage — different limits for different routes:

import { rateLimiter } from '../middleware/rateLimiter.js';

// Strict limit on auth endpoints to prevent brute force
router.post('/auth/login',
  rateLimiter(5, 60),  // 5 attempts per minute per IP
  loginHandler
);

// More generous limit for general API
router.use('/api',
  rateLimiter(200, 60),  // 200 requests per minute per IP
);

// Rate limit by authenticated user ID, not IP
router.use('/api/export',
  authenticate,
  rateLimiter(10, 3600, (req) => `user:${req.user.id}`),  // 10 exports per hour
  exportHandler
);

The Lua script is the key detail here. Without it, INCR and EXPIRE are two separate operations, and a process crash between them leaves keys that never expire — permanent rate limit blocks. Lua scripts execute atomically on the Redis server.


Pattern 3 — Session Storage

The default Express session stores sessions in memory. Fine for a single-server dev setup. Useless once you have two servers or restart your process and log out every user.

npm install express-session connect-redis
// src/app.js
import session from 'express-session';
import RedisStore from 'connect-redis';
import redis from './lib/redis.js';

app.use(session({
  store: new RedisStore({
    client: redis,
    prefix: 'sess:',        // All session keys prefixed for easy identification
    ttl: 86400,             // Session TTL in seconds (24 hours)
  }),
  secret: process.env.SESSION_SECRET,
  resave: false,
  saveUninitialized: false,
  cookie: {
    secure: process.env.NODE_ENV === 'production',  // HTTPS only in production
    httpOnly: true,         // Not accessible via JavaScript
    maxAge: 24 * 60 * 60 * 1000,  // 24 hours in ms
    sameSite: 'strict',
  },
  name: 'sessionId',        // Don't use the default 'connect.sid' — it fingerprints your stack
}));

Manual session operations you will need:

// Store data in session
req.session.userId = user.id;
req.session.role = user.role;
await req.session.save();  // Explicit save when you need it persisted immediately

// Read from session
const userId = req.session.userId;

// Destroy session on logout
router.post('/auth/logout', (req, res) => {
  req.session.destroy((err) => {
    if (err) {
      return res.status(500).json({ error: 'Logout failed' });
    }
    res.clearCookie('sessionId');
    res.json({ success: true });
  });
});

// Invalidate all sessions for a user (e.g. on password change)
async function invalidateAllUserSessions(userId) {
  // Requires scanning for sessions with this user ID
  // Better approach: store session ID -> userId mapping separately
  const sessionKey = `user_sessions:${userId}`;
  const sessionIds = await redis.smembers(sessionKey);

  for (const sessionId of sessionIds) {
    await redis.del(`sess:${sessionId}`);
  }
  await redis.del(sessionKey);
}

Pattern 4 — Pub/Sub for Real-Time Events

When you need to broadcast an event to multiple parts of your application — or across multiple server instances — Redis pub/sub is the simplest solution that does not require a full message queue setup.

// src/lib/pubsub.js
import Redis from 'ioredis';

// Pub/sub requires separate client instances
// A client in subscribe mode cannot run regular commands
const publisher = new Redis({
  host: process.env.REDIS_HOST || 'localhost',
  port: parseInt(process.env.REDIS_PORT || '6379'),
});

const subscriber = new Redis({
  host: process.env.REDIS_HOST || 'localhost',
  port: parseInt(process.env.REDIS_PORT || '6379'),
});

export { publisher, subscriber };
// Publishing an event (e.g. after a user action)
import { publisher } from '../lib/pubsub.js';

async function processOrder(orderId, userId) {
  await db.query('UPDATE orders SET status = $1 WHERE id = $2', ['confirmed', orderId]);

  // Notify any subscribers that this order was confirmed
  await publisher.publish('order:confirmed', JSON.stringify({
    orderId,
    userId,
    confirmedAt: new Date().toISOString(),
  }));
}
// Subscribing to events (e.g. to send a notification)
import { subscriber } from '../lib/pubsub.js';

subscriber.subscribe('order:confirmed', (err) => {
  if (err) console.error('Subscribe error:', err);
});

subscriber.on('message', (channel, message) => {
  if (channel === 'order:confirmed') {
    const { orderId, userId } = JSON.parse(message);
    // Send email, push notification, update analytics, etc.
    sendOrderConfirmationEmail(userId, orderId);
  }
});

This is particularly useful when you have multiple server instances behind a load balancer — a request handled by instance A can publish an event that instance B's subscriber picks up and acts on.


Production Redis Config

A few things that matter before you ship:

Use a password:

# In your Redis Docker run command
docker run -d \
  --name redis-prod \
  -p 6379:6379 \
  redis:7-alpine \
  redis-server --requirepass yourStrongPasswordHere

Never expose Redis port to the internet. Redis should only be reachable from your application servers, not from 0.0.0.0. On your VPS:

# Bind Redis to localhost only (in redis.conf or via command)
redis-server --bind 127.0.0.1 --requirepass yourpassword

Set a maxmemory policy so Redis does not crash when it fills up:

maxmemory 256mb
maxmemory-policy allkeys-lru

allkeys-lru evicts the least recently used keys when memory is full — generally the right policy for a cache.

Monitor with redis-cli:

# Live command stats
redis-cli monitor

# Memory usage
redis-cli info memory

# Key count and hit/miss rate
redis-cli info stats | grep -E "keyspace_hits|keyspace_misses|total_commands"

The Mental Model

Redis is not a database replacement. It is a working memory layer — fast, temporary, and recoverable if it goes down (because your actual data is in Postgres). Design your cache patterns so that losing Redis entirely degrades performance but does not break correctness. If your app cannot function without the cache being warm, you have a deeper problem.

The patterns here — caching, rate limiting, sessions, pub/sub — cover the vast majority of what most Node.js backends need from Redis. Start with caching, add rate limiting on your auth endpoints, and expand from there as you need it.

Z

ZyVOP

Passionate developer sharing knowledge about modern web technologies and best practices.

Comments (0)

Login to post a comment.

Stay Updated

Get the latest articles delivered to your inbox.

We respect your privacy. Unsubscribe anytime.

Popular Tags

#.env.example Node.js#0x profiling#12-factor#AI agents#AI code security#AI coding tools 2026#AI-assisted development#AI-generated vulnerabilities#ALTER TABLE no lock#API Design