API Rate Limiting in Node.js: Protect Your Endpoints
On this page
API Rate Limiting in Node.js: Protect Your Endpoints
Every public API is a target. The moment you expose an endpoint, you invite not just legitimate users but also scrapers, brute-force scripts, credential stuffers, and the occasional runaway client with a broken retry loop. Without guardrails, a single misbehaving caller can exhaust your database connections, spike your cloud bill, or take your service down entirely.
Rate limiting is the simplest, most effective defense. It caps how many requests a client can make in a given window, keeping your API responsive and your infrastructure predictable. In this guide, we'll build up from a basic in-memory limiter to a production-grade, Redis-backed solution — with the trade-offs explained along the way.
Why Rate Limiting Matters
Rate limiting isn't just about blocking bad actors. It serves several purposes at once:
- Abuse prevention — Stops brute-force login attempts and content scraping.
- Fair usage — Ensures one heavy client can't starve everyone else of capacity.
- Cost control — Protects downstream paid services (databases, third-party APIs, LLM tokens) from unbounded calls.
- Stability — Acts as a backpressure mechanism during traffic spikes, degrading gracefully instead of crashing.
Think of it as a circuit breaker for your traffic. Even a generous limit is far better than none.
The Core Algorithms
Before reaching for a library, it helps to understand the main strategies. Each makes a different trade-off between accuracy, memory, and burst tolerance.
Fixed Window
Count requests per client within a fixed time window (e.g. 100 requests per minute). When the clock ticks over, the counter resets. It's trivial to implement but suffers from a boundary problem: a client can send 100 requests at 11:00:59 and another 100 at 11:01:00 — 200 requests in two seconds.
Sliding Window
A refinement that weights the previous window's count to smooth out those boundary bursts. It's more accurate and only marginally more complex, which is why most production limiters use it.
Token Bucket
Each client has a bucket that refills at a steady rate. Every request consumes a token; when the bucket is empty, requests are rejected. This elegantly allows short bursts while enforcing a sustained average rate — ideal for APIs where occasional spikes are legitimate.
Leaky Bucket
Requests queue and drain at a constant rate, smoothing output. Useful when you're protecting a downstream system that needs an even flow rather than bursts.
Getting Started with express-rate-limit
For most Express apps, the express-rate-limit package is the fastest path to a working limiter. Install it:
npm install express-rate-limit
Then apply it as middleware:
import express from "express";
import rateLimit from "express-rate-limit";
const app = express();
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // limit each IP to 100 requests per window
standardHeaders: true, // return RateLimit-* headers
legacyHeaders: false, // disable the X-RateLimit-* headers
message: { error: "Too many requests, please try again later." },
});
app.use(limiter);
app.get("/api/data", (req, res) => {
res.json({ ok: true });
});
app.listen(3000);
That's a functioning rate limiter in about a dozen lines. But the defaults hide some important decisions.
Targeting the Right Routes
Applying one global limit to every route is rarely ideal. A login endpoint needs a much stricter limit than a read-only public feed. Create purpose-built limiters:
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5, // only 5 login attempts per 15 minutes
skipSuccessfulRequests: true, // only count failed attempts
});
app.post("/api/login", authLimiter, loginHandler);
The skipSuccessfulRequests option is a nice touch for auth: it only penalizes failed attempts, so legitimate users who log in correctly aren't affected while brute-force scripts get throttled quickly.
Identifying Clients Correctly
By default, limiters key on IP address. This breaks the moment your app sits behind a load balancer, reverse proxy, or CDN — every request appears to come from the proxy's IP, so you either rate-limit everyone as one client or trust a spoofable header.
If you're behind a proxy, configure Express to trust it and read the forwarded IP correctly:
app.set("trust proxy", 1); // trust first proxy
Be deliberate here. Setting trust proxy to true blindly lets clients spoof X-Forwarded-For and bypass your limits. Trust only the specific number of proxies you actually run.
For authenticated APIs, key on the user or API key rather than IP — it's far more precise:
const limiter = rateLimit({
windowMs: 60 * 1000,
max: 60,
keyGenerator: (req) => req.user?.id ?? req.ip,
});
Scaling Across Multiple Servers
The default in-memory store keeps counters in a single process. Run two instances behind a load balancer and each tracks its own counts — effectively doubling your intended limit. Worse, an autoscaling group multiplies this further.
The fix is a shared store. Redis is the standard choice because its atomic increment operations are perfect for counting:
npm install rate-limit-redis redis
import { RateLimiterRedis } from "rate-limiter-flexible";
import { createClient } from "redis";
const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();
const rateLimiter = new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: "rl",
points: 100, // requests
duration: 60, // per 60 seconds
blockDuration: 60, // block for 60s once exhausted
});
async function rateLimitMiddleware(req, res, next) {
try {
await rateLimiter.consume(req.ip);
next();
} catch (rejRes) {
res.set("Retry-After", String(Math.ceil(rejRes.msBeforeNext / 1000)));
res.status(429).json({ error: "Too many requests" });
}
}
app.use(rateLimitMiddleware);
The rate-limiter-flexible library deserves a special mention: it supports Redis, Memcached, Mongo, and in-memory backends behind one API, and implements token-bucket semantics with block durations out of the box. For anything beyond a hobby project, it's my default recommendation.
Responding the Right Way
How you reject requests matters as much as when. Follow these conventions:
- Use HTTP 429 (
Too Many Requests), not 403 or 503. - Include a
Retry-Afterheader so well-behaved clients know when to come back. - Expose
RateLimit-Limit,RateLimit-Remaining, andRateLimit-Resetheaders so clients can self-throttle before hitting the wall. - Return a clear JSON error body, not an HTML error page, for API consumers.
Good clients will back off gracefully when you give them the information to do so.
Advanced Patterns
Once the basics are in place, consider these refinements for high-traffic or high-value APIs:
- Tiered limits — Give free-tier users tighter caps than paying customers by selecting the limiter based on the authenticated plan.
- Cost-weighted limiting — Charge expensive endpoints more "points" than cheap ones, so a search query might cost 10 tokens while a health check costs 1.
- Dynamic limits — Loosen limits during quiet hours and tighten them under load.
- Global backpressure — Combine per-client limits with a circuit breaker that sheds load when overall system health degrades.
Layering these gives you both fairness between clients and protection for the system as a whole.
Common Pitfalls to Avoid
- Trusting proxy headers blindly — the spoofing risk described above.
- In-memory stores in production — silently ineffective the moment you scale horizontally.
- Limiting too aggressively — frustrates real users and generates support tickets. Start generous, then tighten based on real traffic data.
- Forgetting internal callers — health checks, cron jobs, and service-to-service calls can trip your own limits. Allowlist them explicitly.
- No observability — log and monitor 429 responses so you can tell a genuine attack from a limit that's simply too low.
FAQ
What's a good default rate limit? There's no universal number — it depends on your traffic patterns. A common starting point is 100 requests per 15 minutes per IP for general endpoints, and 5–10 per 15 minutes for sensitive ones like login or password reset. Measure your real traffic and adjust from there.
Should I rate limit by IP or by user? Use the most specific identifier available. For authenticated routes, key on the user ID or API key. Fall back to IP for anonymous traffic — but be aware that shared networks (offices, mobile carriers, NAT) can put many real users behind one IP.
Does rate limiting replace authentication or a WAF? No. Rate limiting is one layer of defense. It complements — but doesn't replace — authentication, input validation, and a web application firewall. Layer them together for real protection.
Will rate limiting slow down my API? The overhead is negligible for in-memory stores and very small for Redis (a single atomic operation per request). The stability you gain far outweighs the cost, and a shared Redis store is easily fast enough for high-throughput APIs.
How do I handle legitimate traffic bursts?
Use a token-bucket algorithm, which permits short bursts while enforcing a sustained average rate. Libraries like rate-limiter-flexible support this natively, so occasional spikes from real users won't get rejected.
What status code should I return?
Always HTTP 429 (Too Many Requests), paired with a Retry-After header telling the client how long to wait. This is the standard that well-behaved HTTP clients understand and respect.
Conclusion
Rate limiting is one of the highest-leverage things you can add to a Node.js API. Start simple with express-rate-limit for a single instance, move to a Redis-backed store like rate-limiter-flexible as soon as you scale beyond one process, and tune your limits based on real traffic rather than guesswork. Pair thoughtful limits with clear 429 responses and good monitoring, and your endpoints will stay fast, fair, and resilient — no matter who comes knocking.