Learning objectives
After reading this article you will be able to:
- Understand how fixed window, sliding window and token bucket limits differ
- Add per-IP rate limiting to an Express API and return 429 with Retry-After
- Test the limit with curl and handle 429 responses in a client
On this page
- What does rate limiting do?
- Which rate limiting algorithm should you use?
- How do you add rate limiting to an Express API?
- How do you test that the limit works?
- What should a 429 response contain?
- Why does rate limiting break with multiple instances?
- Is rate limiting enough to stop attacks?
- How to do this on Square Cloud
- FAQs
Every public API eventually meets a client that sends too much: a script stuck in a loop, a scraper, or someone guessing passwords. Without a limit, one caller can use up the CPU and database connections that everyone else needs.
Rate limiting is the guard against that. Below you will go through the common algorithms, add a limiter to a Node.js API, test it and see how clients should react when they are refused. It assumes you already know the basics of a REST API.
What does rate limiting do?#
A rate limiter sits in front of your route handlers. For each request it picks a key (usually the IP address, API key or user ID), increments a counter for that key, and compares it with the limit for the current window. Under the limit, the request goes through. Over it, the server answers immediately with 429 Too Many Requests and never runs the handler.
It protects you from three things:
- Abuse and brute force: attempts on login, signup and password reset routes.
- Accidents: a buggy client retrying in a tight loop.
- Unfair use: one heavy user slowing down everyone else, and surprise bills from expensive endpoints (database queries, AI calls, e-mail sending).
Rate limiting is not authentication and not a firewall. It controls how often a known key can call, not who is allowed to.
Which rate limiting algorithm should you use?#
| Algorithm | How it works | Good for | Watch out for |
|---|---|---|---|
| Fixed window | Count requests per calendar window (for example per minute) and reset at the boundary | Simple limits, the default in most libraries | A client can send the limit twice in a row around the boundary (end of one window plus start of the next) |
| Sliding window | Count requests in the last N seconds, measured from now | Smooth, fair limits | More memory and work per request |
| Token bucket | A bucket refills at a steady rate; each request spends a token | APIs that allow short bursts but cap the average | Needs two numbers: refill rate and bucket size |
| Concurrency limit | Cap simultaneous in-flight requests | Slow or expensive endpoints | Does not limit total volume |
For most APIs a fixed window is enough to start. Move to a sliding window or token bucket when the boundary burst actually hurts you.
How do you add rate limiting to an Express API?#
You need a recent Node.js version and an Express app. The examples use express-rate-limit 8. The draft-7 headers used below need Express 4.11 or later.
npm install express express-rate-limitCreate server.mjs:
import express from "express";
import { rateLimit } from "express-rate-limit";
const app = express();
// General limit: 5 requests per minute per IP (low on purpose, to make testing easy)
const apiLimiter = rateLimit({
windowMs: 60 * 1000,
limit: 5,
standardHeaders: "draft-7",
legacyHeaders: false,
message: { error: "too_many_requests" },
});
// Stricter limit for a sensitive route: 10 attempts per 15 minutes
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 10,
standardHeaders: "draft-7",
legacyHeaders: false,
});
app.use("/api", apiLimiter);
app.post("/api/login", loginLimiter, (req, res) => res.json({ ok: true }));
app.get("/api/hello", (req, res) => res.json({ hello: "world" }));
app.listen(process.env.PORT || 3000, "0.0.0.0");What the options do:
windowMsis the window length in milliseconds. The default is 60000 (one minute).limitis the maximum number of requests per key in the window. The default is 5. The older namemaxstill works.standardHeaderssends theRateLimitandRateLimit-Policyresponse headers. It is off by default."draft-7"uses a single combinedRateLimitheader.legacyHeaders: falseturns off the oldX-RateLimit-*headers, which are on by default.messageis the body of the 429 response. It can be a string or a JSON object.
A request to /api/login passes through both limiters, the general one first and then the stricter one, so each login attempt counts against both.
What about the IP behind a proxy?#
By default the key is the client IP that Express reports in req.ip. Behind a reverse proxy or load balancer, that is the proxy's address unless you tell Express to trust the X-Forwarded-For header. Without that, every user shares one counter and gets blocked together.
app.set("trust proxy", 1); // one proxy in front of the appA number means that many trusted proxy hops. Avoid true unless the last proxy overwrites X-Forwarded-For, otherwise a client can forge the header and pick its own key. Check what req.ip returns once the app is deployed, and match the setting to the real number of proxies. For background, see reverse proxies.
How do you test that the limit works?#
Start the server and call the same route six times:
PORT=3000 node server.mjs &
for i in 1 2 3 4 5 6; do curl -s -i http://localhost:3000/api/hello | grep -iE "^HTTP|ratelimit|retry|error"; doneThe first five calls return 200 OK and a RateLimit header that counts down. The sixth returns:
HTTP/1.1 429 Too Many Requests
RateLimit-Policy: 5;w=60
RateLimit: limit=5, remaining=0, reset=59
Retry-After: 59
{"error":"too_many_requests"}After about a minute the counter resets and the route answers 200 again. If you never see a 429, check the order: app.use("/api", apiLimiter) must come before the routes it protects.
What should a 429 response contain?#
- Status 429, defined in RFC 6585. Do not use 403 or 500.
Retry-After, which RFC 9110 defines as either a number of seconds or an HTTP date. The server may omit it, but clients depend on it to know when to retry.express-rate-limitsets it for you.- A machine-readable body, such as
{"error":"too_many_requests"}, so clients do not parse prose. RateLimitandRateLimit-Policyheaders so well-behaved clients can slow down before they hit the wall. These come from an IETF draft that is still in progress (draft-ietf-httpapi-ratelimit-headers), not a final standard, so the exact format may still change. Thedraft-7output shown above follows an earlier revision of the draft; newer revisions write the values differently, so check the library docs before parsing these headers. The olderX-RateLimit-*names are common but informal.
Never put secrets or internal details in the error body.
How should a client handle 429?#
Clients should stop and wait instead of retrying immediately. This helper honors Retry-After when present and falls back to exponential backoff otherwise:
async function fetchWithRetry(url, options = {}, maxRetries = 4) {
for (let attempt = 0; ; attempt++) {
const res = await fetch(url, options);
if (res.status !== 429 || attempt >= maxRetries) return res;
const retryAfter = Number(res.headers.get("retry-after"));
const waitMs = Number.isFinite(retryAfter) && retryAfter > 0
? retryAfter * 1000
: 2 ** attempt * 500;
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
}This reads Retry-After as seconds only. The header can also be an HTTP date, so a production client should handle both forms.
Why does rate limiting break with multiple instances?#
The default store keeps counters in the memory of one Node.js process. Two problems follow:
- Restarts reset every counter. A client can wait for a deploy and start fresh.
- Several instances count separately. With three instances and
limit: 100, a client can send up to 300 requests per window, depending on which instance the load balancer picks. The same happens with Node'sclustermodule.
The fix is a shared store. Redis fits well because INCR is atomic and EXPIRE removes old counters on its own, which is the pattern described in the Redis documentation. With rate-limit-redis 6 and the redis client:
npm install rate-limit-redis redisimport { rateLimit } from "express-rate-limit";
import { RedisStore } from "rate-limit-redis";
import { createClient } from "redis";
const client = createClient({ url: process.env.REDIS_URL });
await client.connect();
const apiLimiter = rateLimit({
windowMs: 60 * 1000,
limit: 60,
standardHeaders: "draft-7",
legacyHeaders: false,
store: new RedisStore({
sendCommand: (...args) => client.sendCommand(args),
}),
});Keep the Redis URL in an environment variable, never in the code. If you run a single instance and can accept counters resetting on restart, the in-memory store is fine.
Is rate limiting enough to stop attacks?#
No. A limiter in your app only protects the layers behind it. Every rejected request still costs a connection, parsing and a middleware run. Against volumetric floods you need filtering before traffic reaches your code, which is what DDoS protection does. Keep the in-app limiter for fairness and for abuse of specific routes like login, and review it together with the rest of the API production checklist.
How to do this on Square Cloud#
Square Cloud is self-service hosting: you deploy the API and write the limiter yourself, as shown above. The code runs on the Node.js runtime, in an isolated container that is always on, with no cold start or sleep mode. In-memory counters still reset whenever the process restarts. Web applications start at 512 MB of RAM, and the dashboard lets you set environment variables per application, such as REDIS_URL.
Every plan already includes DDoS protection (L3, L4 and L7) and a WAF in front of your application, so the limiter in your code can focus on per-route and per-key limits. If you run more than one instance and need shared counters, Redis hosting is available from the Standard plan, in the same datacenter as your applications.
See the details and plans in API hosting on Square Cloud.
API hosting
APIs in always-on containers, with no cold starts
FAQs
What HTTP status code means rate limited?
429 Too Many Requests, defined in RFC 6585. The server may add a Retry-After header telling the client how long to wait before trying again.
What is a good rate limit for an API?
There is no universal number. Start from what a normal client needs (for example 60 requests per minute for a public read endpoint), then set much stricter limits on login, signup and password reset routes.
Does rate limiting stop DDoS attacks?
Only small ones. A limiter inside your app still spends CPU and memory on every rejected request, so large floods need to be absorbed upstream, at the network edge, before they reach your code.
Should I rate limit by IP address or by API key?
Use the API key or user ID when the request is authenticated, and the IP address for anonymous routes. IP limits can hit many users behind one shared address, such as an office or a mobile carrier.
Why does my rate limiter not work with several instances?
The default in-memory counter lives inside one process. With several instances or after a restart, each one counts separately, so you need a shared store such as Redis.
More in APIs
See all 15 articles- How to host a Node.js appTo host a Node.js app, prepare package.json, read the port and secrets from the environment, pick an LTS version and run it with node. Step-by-step guide.
- How to host a FastAPI, Flask or Django appTo host a FastAPI, Flask or Django app, run it with a production server (Uvicorn or Gunicorn) on 0.0.0.0. Commands, Django settings and common errors included.
- How to host a Python app or scriptTo host a Python app or script, pin the version, isolate dependencies in a venv, read secrets from the environment and handle SIGTERM. Step-by-step guide.
- What is a cron job? Syntax and examplesA cron job is a task that runs on its own at times set by a five-field expression. Learn the syntax, examples, common mistakes and how to schedule jobs in Node.js.