API rate limiting: what it is and how to add it

API rate limiting caps how many requests one client can send in a time window and rejects the extra ones with HTTP 429. In Node.js, the express-rate-limit middleware adds it in a few lines.

Updated 7 min read

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

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.

requests429limit: 5counterresetsRetry-After0 s60 s120 swindow 1window 2
The counter fills up inside the window; past the limit comes a 429 with Retry-After, and it resets when the window rolls over.

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.

bash
npm install express express-rate-limit

Create server.mjs:

js
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:

  • windowMs is the window length in milliseconds. The default is 60000 (one minute).
  • limit is the maximum number of requests per key in the window. The default is 5. The older name max still works.
  • standardHeaders sends the RateLimit and RateLimit-Policy response headers. It is off by default. "draft-7" uses a single combined RateLimit header.
  • legacyHeaders: false turns off the old X-RateLimit-* headers, which are on by default.
  • message is 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.

js
app.set("trust proxy", 1); // one proxy in front of the app

A 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:

bash
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"; done

The first five calls return 200 OK and a RateLimit header that counts down. The sixth returns:

http
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-limit sets it for you.
  • A machine-readable body, such as {"error":"too_many_requests"}, so clients do not parse prose.
  • RateLimit and RateLimit-Policy headers 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. The draft-7 output 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 older X-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:

js
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's cluster module.

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:

bash
npm install rate-limit-redis redis
js
import { 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

Learn more

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.

Next articleHow to host an APIHosting an API means running its HTTP server around the clock at a public address with HTTPS. Step-by-step with Node.js and Python, testing and common errors.