NodeJSRate Limiting

Rate Limiting

Rate limiting caps how many requests a client may make in a window of time. It protects your API from abuse (brute-force logins, scraping), accidental overload (a buggy client in a loop), and runaway costs — and it keeps one heavy user from degrading service for everyone. This page covers the algorithms, the express-rate-limit middleware, the 429 response, and the production gotchas around proxies and multiple instances.

The algorithms

Algorithm

How it works

Trade-off

Fixed window

N requests per clock window (e.g. per minute)

Simple; allows bursts at window edges

Sliding window

Rolling time window, smoother

More accurate; more state

Token bucket

Tokens refill at a rate; each request spends one

Allows controlled bursts

Leaky bucket

Requests drain at a fixed rate

Smooths output, queues bursts

Fixed window is the common default; token bucket suits bursty traffic
Most APIs start with **fixed-window** limiting (`express-rate-limit`'s default) — easy to reason about. Its weakness is the *boundary burst*: a client can send N requests at the end of one window and N at the start of the next, briefly doubling the rate. **Sliding window** fixes that; **token bucket** is ideal when you want to allow short bursts but bound the sustained rate. Pick based on traffic shape, not theory.
Basic setup with express-rate-limit

JS
import rateLimit from 'express-rate-limit'

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,        // 15 minutes
  max: 100,                         // 100 requests per IP per window
  standardHeaders: 'draft-7',       // send RateLimit-* headers
  legacyHeaders: false,
  message: { error: { code: 'RATE_LIMITED', message: 'Too many requests' } },
})

app.use('/api', limiter)
Tighter limits on sensitive routes

JS
// Brute-force defense: only a handful of login attempts per window.
const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  skipSuccessfulRequests: true,    // only count failed logins
})

app.post('/auth/login', loginLimiter, loginHandler)

// Expensive endpoints (search, export) get their own stricter cap:
app.use('/api/search', rateLimit({ windowMs: 60_000, max: 10 }))
Layer global + per-route limits
Use a generous **global** limit as a blanket safety net, then add **stricter** limits to specific high-risk or expensive routes — login (brute force), password reset, search, report generation. `skipSuccessfulRequests` is perfect for login: it counts only failures, so legitimate users aren't locked out while attackers are throttled.
The 429 response
HTTP/1.1 429 Too Many Requests
RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 42            ← seconds until the window resets
Retry-After: 42

{ "error": { "code": "RATE_LIMITED", "message": "Too many requests" } }
`429` + `Retry-After` tells well-behaved clients when to come back
The correct status for "you've hit the limit" is **429 Too Many Requests**. Send `Retry-After` (seconds, or an HTTP date) so polite clients back off and retry at the right time instead of hammering. The `RateLimit-*` headers let clients track their budget proactively. `express-rate-limit` sets these for you.
Trust proxy — get the real client IP

JS
// Behind nginx, a load balancer, or a CDN, the socket IP is the PROXY's.
// Tell Express to read the client IP from X-Forwarded-For:
app.set('trust proxy', 1)      // number = how many proxies to trust

// Now req.ip (and the limiter's key) is the real client, not the proxy.
Misconfigured `trust proxy` makes rate limiting useless — or spoofable
Behind a proxy, every request arrives with the proxy's IP — so without `trust proxy`, the limiter sees *one* IP for all clients and throttles everyone together (or no one). But set `trust proxy: true` blindly and clients can **spoof** `X-Forwarded-For` to dodge the limit or frame another IP. Set it to the *exact* number of proxies in front of you (`1` for a single load balancer), not a blanket `true`. Getting this wrong silently defeats the whole feature.
Multiple instances need a shared store

JS
import RedisStore from 'rate-limit-redis'
import { createClient } from 'redis'

const redis = createClient({ url: process.env.REDIS_URL })
await redis.connect()

const limiter = rateLimit({
  windowMs: 60_000,
  max: 100,
  store: new RedisStore({ sendCommand: (...args) => redis.sendCommand(args) }),
})
The default in-memory store doesn't work across instances
`express-rate-limit` counts in process memory by default. Run **multiple instances** (cluster, PM2, containers, autoscaling) and each keeps its own count — so the real limit becomes `max × instances`, and a client hitting different instances can exceed it. For any multi-instance deployment, use a **shared store** (Redis) so all instances enforce one global limit. In-memory is fine only for a single process.
Key by user, not just IP, when authenticated

JS
const limiter = rateLimit({
  windowMs: 60_000,
  max: 1000,
  // Authenticated users limited per account; anonymous, per IP:
  keyGenerator: (req) => req.user?.id ?? req.ip,
})
IP keying punishes shared networks; user keying is fairer
Many users behind one corporate NAT or mobile carrier share an IP — IP-based limiting can throttle them all together. For authenticated routes, key on the **user/API-key id** so each account gets its own budget. Fall back to IP for anonymous traffic. Also consider tiered limits (free vs paid) via the key generator and per-tier `max`.
Next
Make your API discoverable and self-documenting: [Documenting APIs (OpenAPI/Swagger)](/nodejs/api-documentation).