API rate limiting in Node.js: a practical guide with real code examples

Rate limiting isn’t optional once your API is public. Without it, one misbehaving client, one scraper, or one brute-force login attempt can take down a service that took months to build. Here’s a working setup you can copy today, plus a dependency-free version if you can’t install anything.

The fast path: express-rate-limit

For most Express APIs, express-rate-limit covers 90% of real-world needs with almost no setup.

npm install express-rate-limit
const rateLimit = require('express-rate-limit');
const express = require('express');
const app = express();

const apiLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,                 // 100 requests per window per IP
  standardHeaders: true,    // return RateLimit-* headers
  legacyHeaders: false,
  message: { error: 'Too many requests, try again later.' },
});

app.use('/api/', apiLimiter);

// Stricter limit for sensitive routes like login
const loginLimiter = rateLimit({
  windowMs: 10 * 60 * 1000,
  max: 5,
  message: { error: 'Too many login attempts.' },
});

app.post('/api/login', loginLimiter, (req, res) => {
  // login logic
});

app.listen(3000);

Put a stricter limiter on auth and password-reset routes specifically — those are the endpoints attackers actually hammer, and a generic global limit is usually too loose to stop credential stuffing.

The no-dependency version

If you can’t add a package, a fixed-window limiter using a Map is enough for a single-instance server.

const requestCounts = new Map();
const WINDOW_MS = 15 * 60 * 1000;
const MAX_REQUESTS = 100;

function rateLimiter(req, res, next) {
  const ip = req.ip;
  const now = Date.now();
  const entry = requestCounts.get(ip);

  if (!entry || now - entry.start > WINDOW_MS) {
    requestCounts.set(ip, { start: now, count: 1 });
    return next();
  }

  if (entry.count >= MAX_REQUESTS) {
    return res.status(429).json({ error: 'Too many requests.' });
  }

  entry.count += 1;
  next();
}

// clean up stale entries periodically
setInterval(() => {
  const now = Date.now();
  for (const [ip, entry] of requestCounts) {
    if (now - entry.start > WINDOW_MS) requestCounts.delete(ip);
  }
}, WINDOW_MS);

app.use('/api/', rateLimiter);

What breaks at scale

  • An in-memory Map only works if you run a single process. Behind a load balancer with multiple instances, each instance has its own counter, so the effective limit multiplies by the number of instances.
  • For multi-instance deployments, back the limiter with Redis so all instances share the same counters — rate-limit-redis plugs directly into express-rate-limit for this.
  • Rate limiting by IP alone fails behind shared NATs or corporate proxies. Combine IP with an API key or user ID when you have one.

Don’t skip the response contract

Always return a 429 status code with a clear JSON error and, ideally, a Retry-After header. Clients (and your own frontend) need a machine-readable way to back off, not just a generic error string.

If you’re deploying this behind Nginx and PM2, see how to deploy a Node.js app with Nginx and PM2 — Nginx itself can also enforce a coarse rate limit at the reverse-proxy layer as a second line of defense.

Quick FAQ

Should rate limiting happen at the app level or the infrastructure level?

Both, ideally. Nginx or a CDN stops obvious floods before they hit your app; app-level limiting handles per-route and per-user logic that infrastructure can’t see.

What’s a reasonable default limit for a public API?

There’s no universal number — base it on your slowest endpoint’s real capacity, then adjust based on actual traffic logs, not guesses.

Does rate limiting protect against DDoS?

Not on its own. It protects against abusive individual clients. Real DDoS mitigation needs infrastructure-level tools (CDN, WAF) in front of your app.

Leave a Reply

Your email address will not be published. Required fields are marked *