Rate limiting your API

Stop abuse and runaway loops with express-rate-limit — return a clean 429 after N requests, choose sensible windows, and move the counter to Redis when one process isn't enough. Verified with a request loop.

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.
  • 429 never 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

Was this guide helpful?