Rate Limiting

intermediate express security rate-limiting redis

Rate limiting caps how many requests a single client (usually identified by IP) can fire at our API in a given window. Without it, one bored attacker with a while true loop can melt our login endpoint or rack up our OpenAI bill.

In simple language: it’s a bouncer that counts how many times you’ve walked into the club tonight and stops letting you in after the limit.

Why we need it

  • Stop brute-force attacks on /login
  • Prevent scraping
  • Protect expensive endpoints (LLM calls, image processing)
  • Save our database from accidental client bugs (a buggy mobile app hammering our server)

The algorithms

Fixed Window
100 req per minute, counter resets at :00
[####------] 4/100
Bursty: can do 200 across minute boundary
Sliding Window
Counts requests in last 60s, rolling
[##-##-####] last 60s
Smoother, no edge bursts
Token Bucket
Bucket holds N tokens, refills at rate R
Bucket: [###--] 3/5
Allows bursts, smooth average

Think of it like this: fixed window is a counter on the wall that resets every hour. Sliding window keeps a rolling list of timestamps. Token bucket is a leaky bucket of coins — you spend one per request, and coins drip back in over time.

Basic setup with express-rate-limit

npm install express-rate-limit
import rateLimit from "express-rate-limit";

const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  limit: 100,               // 100 requests per IP per window
  standardHeaders: "draft-7",
  legacyHeaders: false,
  message: { error: "Too many requests, slow down." },
});

app.use("/api", apiLimiter);

Now any IP hitting /api/* more than 100 times in 15 minutes gets a 429 Too Many Requests.

Tighter limits for sensitive routes

The login endpoint is the juiciest target. Lock it down hard.

const loginLimiter = rateLimit({
  windowMs: 60 * 1000,
  limit: 5,
  skipSuccessfulRequests: true, // only count failed logins
  keyGenerator: (req) => req.body.email || req.ip,
});

app.post("/login", loginLimiter, loginHandler);

skipSuccessfulRequests is gold for login — a legit user who typed their password right shouldn’t get punished. Only bad attempts count.

In-memory vs Redis store

By default, express-rate-limit keeps counters in memory. That works fine for a single-process server. The moment we scale to multiple Node instances behind a load balancer, the math breaks — each instance has its own counter, so an attacker effectively gets limit × instances.

Enter Redis.

npm install rate-limit-redis ioredis
import { RedisStore } from "rate-limit-redis";
import Redis from "ioredis";

const redis = new Redis(process.env.REDIS_URL);

const limiter = rateLimit({
  windowMs: 60 * 1000,
  limit: 100,
  store: new RedisStore({
    sendCommand: (...args) => redis.call(...args),
  }),
});

Now all instances share the same counter. One source of truth.

Trade-offs

StoreProsCons
In-memoryZero deps, fastBreaks across instances, lost on restart
RedisShared state, survives restartsNetwork hop, infra to manage

Gotchas

  • Behind a proxy? Set app.set("trust proxy", 1) so req.ip reads the real client IP from X-Forwarded-For, not your load balancer’s IP.
  • Per-user vs per-IP: for authenticated routes, key on user ID instead of IP — otherwise a whole office NAT shares one bucket.
  • Don’t rate-limit health checks: your monitoring will hate you.