Redis in a Node.js app: cache, invalidate, refresh, rate-limit
One small Express API, four upgrades: connect to Redis, cache a slow endpoint, keep the cache honest after writes, and stop one IP from flooding you.
The project
Connect from Node.js
redis.js · npm i redis
import { createClient } from 'redis';
export const redis = createClient({
url: process.env.REDIS_URL ?? 'redis://localhost:6379',
});
redis.on('error', (err) => console.error('Redis error', err));
await redis.connect(); // one connection, shared by every request
await redis.set('greeting', 'hello', { EX: 60 });
console.log(await redis.get('greeting')); // 'hello'
Create it once at startup and import it. node-redis pipelines concurrent calls on the same connection automatically.
Values are strings. JSON.stringify objects on the way in, JSON.parse on the way out. Numbers come back as strings too.
Always add an error listener — without it, a Redis restart crashes Node. Decide what happens when Redis is down: skip the cache, don't fail the request.
Cache the slow endpoint
routes/products.js
app.get('/products/:id', async (req, res) => {
const key = `cache:v1:product:${req.params.id}`;
const hit = await redis.get(key); // ~1 ms
if (hit) return res.set('X-Cache', 'HIT').json(JSON.parse(hit));
const product = await db.products.findById(req.params.id); // ~300 ms
await redis.set(key, JSON.stringify(product), { EX: 300 });
res.set('X-Cache', 'MISS').json(product);
});
The full read/miss/write-back picture and the stampede are in Use cases → Cache. Here the question is what happens after a write.
Invalidate on write
- PUT /products/42 commits price 399 to Postgres. Redis still holds the old 499.
- Right after the commit, the handler runs DEL cache:v1:product:42.
- The next GET misses, reads 399 from Postgres, and caches it with a fresh TTL.
- Why delete instead of SET the new value? Two concurrent writers can SET in the wrong order and leave an old price cached for the full TTL. A DEL just forces the next read to fetch the truth.
routes/products.js
app.put('/products/:id', async (req, res) => {
const product = await db.products.update(req.params.id, req.body); // 1. truth first
await redis.del(`cache:v1:product:${req.params.id}`); // 2. then drop the copy
res.json(product);
});
// changed the JSON shape? bump the prefix: cache:v2:… — every old entry is ignored at once
Refresh in the background
- Store the value with two clocks: “refresh after 5 min” inside the JSON, and a hard EX 600 on the key.
- Under 5 minutes old: a plain hit.
- Between 5 and 10 minutes: return the cached value immediately, then refresh it in the background. The user never waits for Postgres.
- Only one request should refresh: SET refresh:product:42 1 NX EX 30 works as a tiny lock. Everyone else just serves the cached copy.
- The refresh writes a new value and a new soft time. A popular key never goes cold, so there is no stampede.
cache.js
export async function cached(key, loader, { soft = 300, hard = 600 } = {}) {
const raw = await redis.get(key);
if (raw) {
const { value, refreshAt } = JSON.parse(raw);
if (Date.now() > refreshAt && (await redis.set(`refresh:${key}`, '1', { NX: true, EX: 30 }))) {
refresh(key, loader, soft, hard); // no await: runs after we respond
}
return value;
}
return refresh(key, loader, soft, hard);
}
async function refresh(key, loader, soft, hard) {
const value = await loader();
await redis.set(key, JSON.stringify({ value, refreshAt: Date.now() + soft * 1000 }), { EX: hard });
return value;
}
Play with all three
Lab · one product, three cache strategies
GET twice, then PUT, then GET again. Switch strategies and repeat.
Rate-limit by IP
- A request from 1.2.3.4 arrives. The middleware runs before any route.
- It sends INCR rl:1.2.3.4 and EXPIRE rl:1.2.3.4 60 NX together. The first request of the window creates the key with a 60 s TTL; NX keeps later requests from pushing the TTL back.
- Count ≤ 10: set X-RateLimit-Remaining and call next(). The route runs normally.
- The 11th request inside the minute: 429 Too Many Requests, with Retry-After = the key's TTL.
- 60 s after the first request the key expires and the count starts over. Forget the TTL and this IP stays blocked forever.
Express middleware
middleware/rateLimit.js
export function rateLimit({ limit = 10, windowSec = 60 } = {}) {
return async (req, res, next) => {
const key = `rl:${req.ip}`;
const [count] = await redis.multi()
.incr(key)
.expire(key, windowSec, 'NX') // TTL only on the first hit
.exec();
res.set('X-RateLimit-Limit', String(limit));
res.set('X-RateLimit-Remaining', String(Math.max(0, limit - count)));
if (count > limit) {
res.set('Retry-After', String(await redis.ttl(key)));
return res.status(429).json({ error: 'Too many requests' });
}
next();
};
}
app.set('trust proxy', 1); // behind a load balancer: real client IP
app.use('/api', rateLimit({ limit: 10, windowSec: 60 }));
app.post('/login', rateLimit({ limit: 5, windowSec: 900 }), loginHandler);
Lab · 5 requests per 20 s, per IP
Each IP gets its own key and its own window.
A Map counts per process. Four Node processes behind a load balancer = four separate counters = 4× the limit.
Per IP for anonymous traffic, per user id or API key once logged in, per route for expensive endpoints: rl:login:ip:….
Without trust proxy, every request looks like the load balancer's IP and everyone shares one limit. Fixed windows allow bursts at the edge — see the sliding window.
Quick check
Show answer
The DEL after the database write. Without it, only the TTL (300 s) ends the stale copy.
INCR then EXPIRE key 60 (no NX) on every request. A client sends one request every 50 s. What happens?
Show answer
Each request pushes the TTL back to 60 s, so the key never expires and the count only grows — eventually a slow, honest client gets 429. EXPIRE … NX sets the window once.