ZyVOP Logo
Content That Connects
SeriesAI NewsWhy ZyVOPJoin Discord
LoginGet Started
ZyVOP Logo
Content That Connects

The Developer Publishing Hub. Write once, cross-post to Dev.to, Medium, Hashnode, WordPress & Bluesky with automated canonical source tags and zero paywalls.

Content

  • Categories
  • Tags
  • Badges
  • Leaderboard
  • Write Article
  • Newsletter

Company

  • About Us
  • Why ZyVOP
  • Developer API & CLI
  • Write for Us
  • Contact

Connect

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

© 2026 ZyVOP. Developer Publishing Hub.

Zero paywalls · Full content ownership
All systems operational
HomeAdding Redis to Your Node.js App: Caching, Rate Limiting, and Session Storage

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

ZyVOP
ZyVOP
Senior Developer
May 23, 2026
7 min read
Adding Redis to Your Node.js App: Caching, Rate Limiting, and Session Storage
#Redis Node.js#Redis rate limiting#ioredis tutorial#production Redis setup 2026#session storage Redis#Redis caching Express#Redis pub/sub Node.js
👍1

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.

Comments (0)

Login to post a comment.

ZyVOP
ZyVOP

Founder of Zyvop 🚀 | Building AI-driven tools & premium insights for software engineers, CTOs, and tech leaders. Obsessed with automating workflows and exploring the frontier of AI.

Subscribe to ZyVOP's Newsletter

More from ZyVOP

View profile

Debian Adopts "Responsible Use of Generative AI" After Nine-Way Condorcet Vote

Debian's General Resolution 2026-002 closed on August 28 with "Responsible Use of Generative AI" beating eight rival proposals, including a Social Contract ban, by a clear Condorcet margin, per the project secretary's published beat matrix.

3 minAug 30

How I Built a Real-Time Developer Trend Radar Into My SEO Growth Engine

An AI-powered content intelligence system that streams live developer conversations from Hacker News, Dev.to, Google Search, and GitHub — and turns them into ready-to-write blog opportunities with one click.

12 minAug 29

Qwen3.8-Flash-Next Cost Efficiency, OpenExecutive Satire, and Multi-Vector Retrieval Advances

This week's digest covers Qwen3.8-Flash-Next's push for ultimate cost-efficiency, the viral OpenExecutive project, and the technical release of MultiVectorEncoder in Sentence-Transformers v6.0.

4 minAug 28

Anthropic’s Pricing Shock, Granite 4.2 Open‑Source Leap, and AI‑Powered Security & Policy Shifts

From Anthropic’s flagship model losing steam to IBM’s 512 K‑token Granite 4.2, plus a new wave of AI‑driven security exploits and policy alarms, this week’s digest maps the technical and market forces you need to act on now.

3 minAug 26

Introducing Questions and Discussions: A New Way to Connect!

We are thrilled to announce a major update to how you can interact and share content on our platform! Up until now, sharing your thoughts meant writing a standa...

2 minAug 1