What you'll build
By the end of this tutorial you will have an Express and TypeScript API that rate limits its callers, with the counters stored in Redis instead of in the server's memory. It has three routes so you can see the pattern from every angle: a GET /api/data with a generous limit (100 requests per 15 minutes), a POST /api/expensive with a strict limit (5 requests per minute, the kind of cap you put on something that costs money or CPU), and a GET /health with no limit at all, because a health check should always answer.
The interesting part is not the middleware itself, it is where the counts are kept. We build one small limiter factory so both routes share a single Redis connection but keep their own window, cap, and counter namespace. Every file you need is on this page. Paste each one into a fresh project, start a local Redis, and it runs. No repo to clone.
The honest verdict up front
express-rate-limit works out of the box with zero configuration, and its default store keeps the counters in the memory of one Node process. That is fine on your laptop, and it breaks in two quiet ways once you ship it. First, every restart wipes the counters, so a deploy hands every client a fresh allowance. Second, the moment you run more than one instance behind a load balancer, each instance counts on its own, so a client whose requests land on three servers effectively gets three times the limit you set. Neither failure throws an error. The limiter just silently stops doing its job.
Redis fixes both. The counters live in one shared place that every instance reads and writes, and they survive a restart. That is why production APIs back their rate limits with Redis (or another shared store) rather than process memory. The trade-off is honest too: Redis is one more piece of infrastructure to run and pay for. If your API is a single-instance side project that rarely restarts, the in-memory store is genuinely fine, and you can drop Redis by deleting one option (the FAQ at the end shows exactly what to remove). Reach for Redis when you run more than one instance, when a restart resetting everyone's limit would matter, or when you want limits that hold steady across deploys.
Setup: project, packages, and a local Redis
Start with a fresh Node project and the packages. express, express-rate-limit, rate-limit-redis, and redis are the four runtime dependencies; TypeScript, tsx, and the @types/* packages are dev tooling for building and running the .ts files.
mkdir rate-limited-api
cd rate-limited-api
npm init -y
npm install express express-rate-limit rate-limit-redis redis
npm install -D typescript tsx @types/node @types/expressNow you need a Redis to point at. Any Redis works. The quickest local option is Docker, one command that runs Redis on the default port 6379:
docker run --name rate-limit-redis -p 6379:6379 -d redisNo Docker? A native install works the same way: brew install redis && brew services start redis on a Mac, or your package manager on Linux. However you run it, the tutorial expects Redis listening on localhost:6379. You can confirm it is up with redis-cli ping, which should answer PONG.
Add a tsconfig.json at the project root. These settings run cleanly with tsx in development and compile with tsc for a production build:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}Then open package.json, add "type": "module", and set these three scripts. dev runs the app straight from source and restarts on every save, build compiles to dist/, and start runs the compiled output the way a production host would:
{
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
}
}All three source files below go in a src/ directory you create in the project root.
The Redis client
First, one shared Redis connection for the whole app. The limiter store will use this to read and write its counters, so you want a single client, connected once at startup, not a new connection per request. The node-redis client is event-based: it emits error if the connection drops, and you call connect() once before the server starts listening.
Create src/redis.ts:
import { createClient } from "redis";
// One shared Redis client for the whole app. The rate limiter stores its
// counters here, so limits are shared across every server instance and
// survive a restart.
export const redisClient = createClient({
url: process.env.REDIS_URL ?? "redis://localhost:6379",
});
// node-redis emits "error" if the connection drops. Log it instead of
// letting an unhandled error event crash the process.
redisClient.on("error", (err) => console.error("Redis client error:", err));
// Call this once at startup, before the server listens.
export async function connectRedis(): Promise<void> {
if (!redisClient.isOpen) {
await redisClient.connect();
}
}The REDIS_URL fallback means the app runs with zero config locally and still reads a real connection string from the environment in production, where Redis lives on another host. Nothing here is specific to rate limiting yet; this is just a well-behaved Redis client you would reuse for caching or sessions too.
The rate limiter: one factory, many limits
This is the core of the tutorial. Instead of hand-writing a limiter for each route, write one factory that takes a window, a request cap, and a Redis key prefix, and returns a configured limiter. That way both routes share the single Redis client but keep their own counters.
Create src/rate-limit.ts:
import rateLimit from "express-rate-limit";
import { RedisStore } from "rate-limit-redis";
import { redisClient } from "./redis.js";
// A factory so every route can share one Redis client but keep its own
// window, limit, and counter namespace. Give each limiter a distinct
// `prefix` or they will all count against the same bucket.
export function makeRateLimiter(options: {
windowMs: number;
limit: number;
prefix: string;
}) {
return rateLimit({
windowMs: options.windowMs,
limit: options.limit,
// Send the standard RateLimit-Limit, RateLimit-Remaining, and
// RateLimit-Reset headers (the IETF draft-6 format).
standardHeaders: "draft-6",
// Drop the older X-RateLimit-* headers; nothing modern needs them.
legacyHeaders: false,
message: { error: "Too many requests. Please slow down and try again later." },
store: new RedisStore({
prefix: options.prefix,
// rate-limit-redis speaks raw Redis commands; hand it node-redis's
// sendCommand so it can INCR and read counters on your client.
sendCommand: (...args: string[]) => redisClient.sendCommand(args),
}),
});
}Three things earn a second look here. The store is what moves the counters off process memory and into Redis; delete that one option and you are back to the default in-memory store. The prefix is not optional bookkeeping: rate-limit-redis namespaces every key with it, so if two limiters share a prefix they share a counter, and your strict POST limit would eat into your generous GET limit. Give each limiter its own prefix. And standardHeaders: "draft-6" is what produces the separate RateLimit-Remaining and RateLimit-Reset headers a client reads to back off politely, plus the Retry-After header on a 429.
Shore up your backend fundamentals
Rate limiting is one slice of building an API that survives real traffic. freeCodeCamp's Back End Development and APIs certification teaches Express and middleware from scratch for free, and our backend developer learn path sequences the strongest free options in order. Keep the Node.js cheat sheet open while you build.
Wire up the routes
Now the app. Build two limiters from the factory (a generous one for reads, a strict one for the expensive write), connect to Redis, then start the server. A limiter is just Express middleware, so you apply it by listing it before the route handler. The /health route gets no limiter at all.
Create src/index.ts:
import express, { type Request, type Response } from "express";
import { connectRedis } from "./redis.js";
import { makeRateLimiter } from "./rate-limit.js";
const app = express();
app.use(express.json());
// Generous limit for normal reads: 100 requests per 15 minutes per IP.
const readLimiter = makeRateLimiter({
windowMs: 15 * 60 * 1000,
limit: 100,
prefix: "rl:read:",
});
// Strict limit for an expensive write: 5 requests per minute per IP.
const expensiveLimiter = makeRateLimiter({
windowMs: 60 * 1000,
limit: 5,
prefix: "rl:expensive:",
});
// No limiter here: a health check should always answer.
app.get("/health", (_req: Request, res: Response) => {
res.json({ status: "ok" });
});
// Apply a limiter by listing it before the handler.
app.get("/api/data", readLimiter, (_req: Request, res: Response) => {
res.json({ data: [1, 2, 3], servedAt: new Date().toISOString() });
});
app.post("/api/expensive", expensiveLimiter, (req: Request, res: Response) => {
const { input } = req.body ?? {};
// Pretend this calls a paid API or runs something heavy.
res.status(201).json({ ok: true, echo: input ?? null });
});
const port = process.env.PORT ?? 3000;
// Connect to Redis first, then start listening. If Redis is down, fail
// loudly at startup instead of on the first rate-limited request.
connectRedis()
.then(() => {
app.listen(port, () => {
console.log(`API listening on http://localhost:${port}`);
});
})
.catch((err) => {
console.error("Failed to connect to Redis:", err);
process.exit(1);
});One real-world gotcha to know before you deploy this. express-rate-limit identifies a client by its IP address, and it reads that from req.ip. Behind a reverse proxy or load balancer (Nginx, a cloud load balancer, Render, Fly.io), req.ip is the proxy's address unless you tell Express to trust the X-Forwarded-For header with app.set("trust proxy", 1). Set it to the number of proxies in front of you, not blindly to true, because a client can forge that header and dodge the limit if you trust it wholesale. Locally, with no proxy, you need none of this.
Run it and trigger a 429
Make sure Redis is running (the Docker container from earlier, or your native install), then start the server in dev mode. It restarts automatically when you save a file:
npm run dev
# -> API listening on http://localhost:3000The generous read route is hard to trip by hand (100 requests), so aim at the strict one: 5 requests per minute. Fire six of them in a loop and watch the sixth get refused. This small bash loop uses -i so you can see the headers on every response:
for i in $(seq 1 6); do
echo "--- request $i ---"
curl -s -i -X POST http://localhost:3000/api/expensive \
-H "Content-Type: application/json" \
-d '{"input":"hello"}' \
| grep -E "^HTTP|^RateLimit|^Retry-After"
doneThe first five come back HTTP/1.1 201 Created, with RateLimit-Remaining counting down 4, 3, 2, 1, 0 and RateLimit-Reset showing the seconds until the window rolls over. The sixth flips to HTTP/1.1 429 Too Many Requests, adds a Retry-After: 60 header (wait this many seconds), and returns the JSON body you set: {"error":"Too many requests. Please slow down and try again later."}. A well-behaved client reads Retry-After and backs off instead of hammering.
Now prove the Redis part is real. Stop the server with Ctrl-C and start it again with npm run dev. Immediately fire the loop once more. The limit is already partly used up, because the counter is in Redis, not in the process you just restarted. That is the whole point: with the default in-memory store, a restart would have handed you a fresh 5 requests. To reset a counter yourself during development, run redis-cli --scan --pattern 'rl:*' | xargs redis-cli del to clear the limiter keys.
Where to take it next
You now have the production shape of API rate limiting: shared counters, per-route limits, standard headers. Three moves take it further.
Limit by user, not just IP. Right now everyone behind one IP (an office, a school, a mobile carrier) shares a limit. Once you have authentication, key the limiter on the user id instead with the keyGenerator option, so each account gets its own budget. Our JWT authentication tutorial builds the login flow and the req.user this needs.
Put it on a real API. This tutorial's routes are stand-ins. Drop these two limiters onto the CRUD API from our Prisma and PostgreSQL tutorial and you have an API that both persists data and defends itself.
Tune the limits and the window. Watch your real traffic before you pick numbers. A public read endpoint might allow hundreds per minute; a password-reset or payment route might allow three per hour. The factory makes each one a one-line change. For the full course path around backend work, the backend developer roadmap shows what comes after this.
Frequently asked questions
Why use Redis instead of the default in-memory store?
The default express-rate-limit store keeps counters in one Node process's memory. That breaks in production in two ways: a restart or deploy wipes every counter (so limits reset), and running more than one instance means each one counts separately (so a client that hits three servers gets three times the limit). Redis holds the counters in one shared place that every instance reads and writes, and they survive a restart. It is the standard choice for any API that runs more than a single process.
Do I need Redis if I only ever run one server instance?
No. If your API is a single instance that rarely restarts, the built-in in-memory store is genuinely fine, and skipping Redis removes a dependency you have to run and pay for. To drop it, delete the store option from the makeRateLimiter factory and remove the redis and rate-limit-redis packages plus src/redis.ts; the limiter falls back to memory automatically. Reach for Redis the moment you scale past one instance or need limits to hold across restarts.
What does a client see when it's rate limited?
An HTTP 429 Too Many Requests response. Because this tutorial sets standardHeaders to 'draft-6', every response also carries RateLimit-Limit (the cap), RateLimit-Remaining (how many requests are left in the window), and RateLimit-Reset (seconds until the window resets). The 429 specifically adds a Retry-After header telling the client how many seconds to wait, plus the JSON error body you configured. A good client reads Retry-After and backs off instead of retrying immediately.
Can I rate limit by user ID instead of IP?
Yes. By default express-rate-limit keys the counter on the client's IP. Pass a keyGenerator function to the limiter to key on anything you like, for example (req) => req.user?.id ?? req.ip once you have authentication attaching req.user. User-based keying is fairer than IP-based, because many people can share one IP (an office or a mobile carrier), and it stops one user on a big NAT from starving everyone else behind it.
How do I give different routes different limits?
That is what the factory is for. Call makeRateLimiter once per limit you want, each with its own windowMs, limit, and prefix, then apply each returned limiter to the routes that should share it. This tutorial uses a generous 100-per-15-minutes limiter on reads and a strict 5-per-minute limiter on the expensive route. The prefix keeps their Redis counters separate; reuse the same prefix and the routes would share one bucket.
Why does each limiter need its own Redis prefix?
rate-limit-redis namespaces every counter key with the prefix you give it. If two limiters use the same prefix, they read and write the same keys, so a request to your strict route would count against your generous route and vice versa. Distinct prefixes (like 'rl:read:' and 'rl:expensive:') keep each limiter's counting independent. It is the one detail that quietly breaks limits if you get it wrong.
Does this work correctly behind a proxy or load balancer?
Only after you tell Express to trust the proxy. Rate limiting keys on req.ip, and behind Nginx or a cloud load balancer req.ip is the proxy's address unless you set app.set('trust proxy', 1), which makes Express read the real client IP from the X-Forwarded-For header. Set it to the number of proxies in front of you rather than true, because a client can forge that header and dodge the limit if you trust it blindly. Locally, with no proxy, you do not need it.