An open API is an open invitation — to a buggy client stuck in a loop, a scraper hammering your endpoints, or someone brute-forcing a login. Rate limiting caps how many requests one caller can make in a window, and answers the rest with 429 Too Many Requests instead of falling over. This guide sets it up with express-rate-limit, then shows the honest upgrade to a shared Redis counter.
| At a glance | |
|---|---|
| You need | An Express API on the Node.js application |
| Background | Designing a small REST API |
| Plan | Any plan |
| Time | Twenty minutes |
The basic limiter
express-rate-limit counts requests per client (by IP) and blocks the ones over your limit. Install it from the Packages page (search express-rate-limit, install, restart), then wrap the routes you want to protect:
const express = require('express');
const rateLimit = require('express-rate-limit');
const app = express();
const limiter = rateLimit({
windowMs: 60 * 1000, // the window: one minute
limit: 5, // max requests per window, per IP
standardHeaders: 'draft-7', // send RateLimit headers
legacyHeaders: false,
message: { error: 'Too many requests, slow down.' },
});
app.use('/api/', limiter); // protect everything under /api/
app.get('/api/ping', (req, res) => res.json({ ok: true }));
const PORT = process.env.SERVER_PORT || 8080;
app.listen(PORT, '0.0.0.0', () => console.log(`Listening on port ${PORT}`));
windowMs and limit are the whole policy: "no more than limit requests per windowMs, per IP." Everything over the line gets an automatic 429 with your message body — you don't write that handler.
Verify it works
Start the server and fire more requests than the limit with a quick loop:
for i in $(seq 1 8); do
curl -s -o /dev/null -w "request $i -> %{http_code}\n" http://YOUR_ADDRESS:PORT/api/ping
done
With limit: 5 you'll see the first five return 200 and the rest return 429:
request 1 -> 200
request 2 -> 200
request 3 -> 200
request 4 -> 200
request 5 -> 200
request 6 -> 429
request 7 -> 429
request 8 -> 429
A 429 response also carries headers a well-behaved client reads to back off:
RateLimit-Policy: 5;w=60
RateLimit: limit=5, remaining=0, reset=60
Retry-After: 60
Retry-After: 60 tells the caller to wait 60 seconds. That's the limiter doing exactly its job.
Choosing windows
One global limit rarely fits everything. Give expensive or sensitive routes their own, stricter limiter and leave cheap reads generous:
| Route type | A reasonable start |
|---|---|
General reads (GET) |
60–100 per minute |
Writes (POST/DELETE) |
20–30 per minute |
| Login / password endpoints | 5–10 per 15 minutes — brute-force defence |
| Expensive jobs (uploads, reports) | A handful per minute |
Create a second rateLimit({...}) with tighter numbers and apply it to just those routes:
const loginLimiter = rateLimit({ windowMs: 15 * 60 * 1000, limit: 10 });
app.post('/login', loginLimiter, handleLogin);
🎯 Good to know: Pairing a strict login limiter with hashed passwords is the standard defence against credential-stuffing — see Sessions, JWT & passwords.
The honest limit of the default store
Out of the box, express-rate-limit keeps its counters in memory. For a single Falix app server that's genuinely fine — you have one process on one port, so one memory counts everything. But know its two edges:
- Counters reset when the app restarts. A redeploy or the free session timer wipes the tally and everyone starts fresh. For basic abuse protection that's acceptable.
- It only counts within one process. If you ever run multiple processes or instances sharing the load, each keeps its own count, so the real limit multiplies. One process — the normal case here — isn't affected.
When either matters, move the counter to Redis so all processes share one tally.
Sharing the counter with Redis
A Redis server (a Redis application on Falix, or a managed one) holds the counts centrally. Install rate-limit-redis and redis from the Packages page, then point the limiter's store at it:
const rateLimit = require('express-rate-limit');
const { RedisStore } = require('rate-limit-redis');
const { createClient } = require('redis');
const client = createClient({ url: process.env.REDIS_URL }); // from .env
await client.connect();
const limiter = rateLimit({
windowMs: 60 * 1000,
limit: 5,
standardHeaders: 'draft-7',
legacyHeaders: false,
store: new RedisStore({ sendCommand: (...args) => client.sendCommand(args) }),
});
app.use('/api/', limiter);
Only the store line changes; the limiter behaves identically — five requests, then 429 — but now the count lives in Redis (keys like rl:<ip>) and survives an app restart, shared across every process pointed at that Redis. Keep the connection URL, with its password, in .env (Environment variables & secrets).
⚠️ Heads up: Redis is now a dependency your app needs to reach. If Redis is unreachable, decide deliberately: fail requests, or fall back to the in-memory store. Don't leave it undefined.
A layer below your code
Rate limiting in your app is per-route and understands your logic — the right place for "10 logins per 15 minutes." Your server also has a platform-level Firewall page with its own Rate limiting rules that act on raw connections before they reach Node, useful against blunt floods. They complement each other: the firewall stops the crudest abuse at the edge, your limiter enforces the precise, per-endpoint rules. For a genuine DDoS, the firewall's DDoS protection is the relevant layer, not application code.
The library's own docs at express-rate-limit.mintlify.app cover key generators (limiting by user id instead of IP), skip functions, and every store.
Troubleshooting
- Everyone shares one limit / all traffic looks like one IP — behind the Reverse Proxy, requests arrive from the proxy unless Express trusts it. Set
app.set('trust proxy', 1)so the limiter sees the real client IP (Domains and HTTPS). - Limits reset unexpectedly — the in-memory store cleared on an app restart. Expected; move to Redis if you need persistence.
429never triggers — the limiter isn't in front of the route.app.use('/api/', limiter)must come before the route handlers it should guard.- Legitimate users hit the wall — your window is too tight. Loosen
limit, or give heavy routes their own limiter and keep the strict one for login.
Next steps
- Sessions, JWT & passwords — throttle logins with a strict limiter
- Redis caching — the shared store for counters
- CORS errors explained — the other API middleware you'll add
- Designing a small REST API — the API you're protecting