Foundations 4 of 5 · Project chapter

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

Project architecture Clients call an Express API. A rate-limit middleware and a cache sit in front of a slow Postgres query that takes about 300 milliseconds. Redis holds the cache and the rate-limit counters. clients Express API rateLimit() 10 req / min / IP GET /products/:id cache-aside Redis cache:* · rl:* · ~1 ms Postgres products · ~300 ms
Redis stores two kinds of keys here: cached products and per-IP counters. Postgres stays the source of truth.

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'
One client per process

Create it once at startup and import it. node-redis pipelines concurrent calls on the same connection automatically.

Strings in, strings out

Values are strings. JSON.stringify objects on the way in, JSON.parse on the way out. Numbers come back as strings too.

Watch out

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

Delete the cache key after a write A PUT updates the price in Postgres, then deletes the cache key. The next GET misses, reads the new price, and caches it again. client PUT 399 API /products/42 DEL cache:v1:product:42 UPDATE … price=399 Redis product:42 → 499 (old) Postgres price = 399
  1. PUT /products/42 commits price 399 to Postgres. Redis still holds the old 499.
  2. Right after the commit, the handler runs DEL cache:v1:product:42.
  3. The next GET misses, reads 399 from Postgres, and caches it with a fresh TTL.
  4. 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.
Write the database first, then delete the cache key. The TTL is the safety net if the DEL is ever missed.

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

Soft TTL with background refresh A cached value is fresh for five minutes. Between five and ten minutes it is served immediately while one request refreshes it in the background. After ten minutes the key is gone and the slow path runs. 0 5 min · soft 10 min · hard (EX 600) fresh plain hit · ~1 ms stale but usable serve now · refresh in background gone slow SET refresh:product:42 1 NX EX 30 only the request that gets OK refreshes refresh rewrites value · soft clock back to 0 age of the cached value →
  1. Store the value with two clocks: “refresh after 5 min” inside the JSON, and a hard EX 600 on the key.
  2. Under 5 minutes old: a plain hit.
  3. Between 5 and 10 minutes: return the cached value immediately, then refresh it in the background. The user never waits for Postgres.
  4. 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.
  5. The refresh writes a new value and a new soft time. A popular key never goes cold, so there is no stampede.
Also called stale-while-revalidate. Only cold, rarely read keys ever pay the slow path.

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

    Rate-limit middleware A request from IP 1.2.3.4 enters the middleware, which increments a per-IP counter in Redis with a 60 second TTL. Under the limit it calls next; over the limit it answers 429. request IP 1.2.3.4 rateLimit() limit 10 / 60 s INCR + EXPIRE NX rl:1.2.3.4 → count · TTL next() route handler 200 OK count > 10 429 Retry-After: 17 X-RateLimit-Remaining tells well-behaved clients when to slow down
    1. A request from 1.2.3.4 arrives. The middleware runs before any route.
    2. 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.
    3. Count ≤ 10: set X-RateLimit-Remaining and call next(). The route runs normally.
    4. The 11th request inside the minute: 429 Too Many Requests, with Retry-After = the key's TTL.
    5. 60 s after the first request the key expires and the count starts over. Forget the TTL and this IP stays blocked forever.
    One counter per IP per window. The TTL is the window.

    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.

    Why Redis, not a JS Map?

    A Map counts per process. Four Node processes behind a load balancer = four separate counters = 4× the limit.

    Pick the key

    Per IP for anonymous traffic, per user id or API key once logged in, per route for expensive endpoints: rl:login:ip:….

    Watch out

    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

    You update a product but users still see the old price for 5 minutes. What is missing?
    Show answer

    The DEL after the database write. Without it, only the TTL (300 s) ends the stale copy.

    Your limiter uses 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.