Redis for Caching & Sessions
Redis is an in-memory data store that Express apps reach for whenever they need something fast and shared across processes. The same instance can cache expensive database results, hold session data so users stay logged in across a horizontally scaled cluster, and track rate-limit counters. Because everything lives in memory, reads and writes happen in microseconds — but you trade durability for that speed, so Redis is for data you can afford to recompute or lose. This page wires Redis into Express for all three jobs using ioredis, the most feature-complete Node client.
Connecting with ioredis
ioredis and node-redis are the two mainstream clients. ioredis has first-class support for Redis Cluster, Sentinel, and Lua scripting, and its API is promise-based out of the box, which fits async Express handlers cleanly. Create a single client at startup and share it across every request — the client multiplexes commands over one connection, so there is no per-request connection cost.
npm install express ioredis
// redis.js
const Redis = require('ioredis');
const redis = new Redis(process.env.REDIS_URL || 'redis://127.0.0.1:6379', {
maxRetriesPerRequest: 3, // fail fast instead of queueing forever
enableReadyCheck: true,
});
redis.on('error', (err) => console.error('Redis error', err));
redis.on('ready', () => console.log('Redis connected'));
module.exports = redis;
The client auto-reconnects with exponential backoff. Setting
maxRetriesPerRequestprevents a request from hanging indefinitely when Redis is unreachable — it rejects the command so your route can fall back to the database.
Here is a quick comparison of the two clients to help you choose.
| Feature | ioredis | node-redis (v4+) |
|---|---|---|
| Promise API | Built in | Built in |
| Cluster / Sentinel | Native | Native |
| Lua scripting helpers | defineCommand | eval only |
| Auto pipelining | Yes | Manual |
| Bundle weight | Heavier | Lighter |
Cache-aside in a route
The cache-aside (lazy loading) pattern is the workhorse of API caching. On each request you check Redis first; on a miss you read from the source of truth, store the result with a TTL, then return it. The TTL bounds how stale data can get without any explicit invalidation.
// routes/products.js
const express = require('express');
const router = express.Router();
const redis = require('../redis');
const db = require('../db'); // your pg/mongoose layer
const TTL_SECONDS = 60; // accept up to 60s of staleness
router.get('/:id', async (req, res, next) => {
const key = `product:${req.params.id}`;
try {
const cached = await redis.get(key);
if (cached) {
res.set('X-Cache', 'HIT');
return res.json(JSON.parse(cached));
}
const product = await db.findProduct(req.params.id);
if (!product) return res.status(404).json({ error: 'Not found' });
// EX sets expiry atomically with the write
await redis.set(key, JSON.stringify(product), 'EX', TTL_SECONDS);
res.set('X-Cache', 'MISS');
res.json(product);
} catch (err) {
next(err); // forward to error-handling middleware
}
});
module.exports = router;
Mount it the usual way and the first request populates the cache while the rest are served from memory.
curl -i http://localhost:3000/products/42
Output:
HTTP/1.1 200 OK
X-Cache: MISS
Content-Type: application/json; charset=utf-8
{"id":42,"name":"Mechanical Keyboard","price":129.99}
A second request within the TTL window returns X-Cache: HIT and never touches the database. When the underlying record changes, delete the key (await redis.del(\product:${id}`)`) inside your update handler so the next read repopulates with fresh data.
Storing sessions in Redis
The default express-session store keeps sessions in process memory, which leaks and breaks the moment you run more than one instance. connect-redis moves sessions into Redis so any instance behind a load balancer can read them, and sessions survive a process restart.
npm install express-session connect-redis
const session = require('express-session');
const { RedisStore } = require('connect-redis');
const redis = require('./redis');
app.use(
session({
store: new RedisStore({ client: redis, prefix: 'sess:' }),
secret: process.env.SESSION_SECRET,
resave: false, // don't rewrite unchanged sessions
saveUninitialized: false, // don't store empty sessions
cookie: {
httpOnly: true,
secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
maxAge: 1000 * 60 * 60 * 24, // 1 day
},
})
);
The store reuses your existing ioredis client, and prefix namespaces session keys so they are easy to spot and flush. Redis applies the cookie maxAge as the key TTL, so expired sessions clean themselves up.
Backing a rate limiter
Rate limiting only works if every instance shares one counter, which makes Redis the natural backend. express-rate-limit ships a rate-limit-redis store that increments counters atomically with INCR.
npm install express-rate-limit rate-limit-redis
const rateLimit = require('express-rate-limit');
const { RedisStore } = require('rate-limit-redis');
const redis = require('./redis');
const limiter = rateLimit({
windowMs: 60 * 1000, // 1 minute window
max: 100, // 100 requests per IP per window
standardHeaders: true,
legacyHeaders: false,
store: new RedisStore({
sendCommand: (...args) => redis.call(...args),
}),
});
app.use('/api', limiter); // shared across all instances
Because the counter lives in Redis, scaling out to ten instances still enforces a single global limit per client.
Express 5 changes nothing about how these middlewares attach, but it does propagate rejected promises from async handlers automatically — in Express 4 you must call
next(err)yourself (as the cache route does above).
Best Practices
- Create one Redis client at startup and share it; never connect per request.
- Always set a TTL on cache keys so stale data eventually self-corrects even if you forget to invalidate.
- Namespace keys with prefixes like
product:,sess:, andrl:for clarity and targeted flushing. - On a Redis outage, degrade gracefully — fall back to the database rather than failing the request.
- Store only data you can recompute or afford to lose; treat your primary database as the source of truth.
- Use
secureandhttpOnlycookies for sessions, and keepSESSION_SECRETout of source control.