0x55aa
← Back to Blog

🚦 Node.js Rate Limiting: Stop the Stampede Before It Destroys Your API

6 min read

🚦 Node.js Rate Limiting: Stop the Stampede Before It Destroys Your API

Imagine you open a coffee shop. No rules, no limits — every customer can order as many cups as they want, as fast as they want. Day one? Great. Day two? One guy shows up with a garden hose and drains your entire espresso machine in 30 seconds. Your other customers leave. You cry. Your barista quits.

That's your API without rate limiting.

Rate limiting is the bouncer at the door of your backend — it decides who gets in, how often, and when to say "you've had enough, friend." Without it, you're one viral Reddit post (or one malicious bot) away from a very bad day.

Why You Actually Need This

Here's what happens to unprotected APIs in the wild:

  • Scraping bots hammer your endpoints thousands of times per minute, stealing your data
  • Credential stuffing attacks try millions of username/password combos against your login route
  • Accidental DoS — a developer writes a broken script that hits your API in a tight loop (we've all been there)
  • Cost explosions on pay-per-request third-party services you call downstream

The good news: adding rate limiting to an Express app takes about 10 minutes. The bad news: you've probably already waited too long.

The Quick Win: express-rate-limit

The fastest way to protect your Express API is the express-rate-limit package. It's battle-tested, simple, and works globally or per-route.

npm install express-rate-limit

Here's the most common setup — a global limiter that applies to every route:

import express from 'express';
import rateLimit from 'express-rate-limit';

const app = express();

// Global limiter: 100 requests per 15 minutes per IP
const globalLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  max: 100,
  standardHeaders: true,   // Return rate limit info in RateLimit-* headers
  legacyHeaders: false,
  message: {
    status: 429,
    error: 'Too many requests. Slow your roll.',
  },
});

app.use(globalLimiter);

app.get('/api/products', (req, res) => {
  res.json({ products: [] });
});

app.listen(3000, () => console.log('Server running on port 3000'));

When a client exceeds the limit, they get a 429 Too Many Requests response and the Retry-After header tells them when to try again. Clean, automatic, no drama.

Surgical Limits for Sensitive Routes

Global limits are great, but some routes deserve extra protection. Your login endpoint shouldn't have the same tolerance as your public product catalog.

Think of it like this: at your coffee shop, anyone can look at the menu all day long — but the espresso machine is off-limits after three cups.

import rateLimit from 'express-rate-limit';

// Strict limiter for auth routes: 5 attempts per 15 minutes
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 5,
  skipSuccessfulRequests: true, // Only count failed attempts
  message: {
    status: 429,
    error: 'Too many login attempts. Take a breather.',
  },
});

// Moderate limiter for search (it's expensive server-side)
const searchLimiter = rateLimit({
  windowMs: 60 * 1000, // 1 minute
  max: 20,
});

// Apply per-route
app.post('/api/auth/login', authLimiter, loginController);
app.post('/api/auth/register', authLimiter, registerController);
app.get('/api/search', searchLimiter, searchController);

The skipSuccessfulRequests: true flag is a nice touch for login routes — it only counts failed attempts toward the limit. Legitimate users who type their password correctly aren't penalized. Bots trying thousands of combos? They hit the wall fast.

Going Beyond Memory: Redis-Backed Rate Limiting

The default express-rate-limit stores request counts in memory. That works fine for a single server, but the moment you scale horizontally (multiple Node processes, multiple containers), each instance has its own counter. A bot can bypass your limit just by hitting different instances.

The fix: use a shared store like Redis. Every instance reads from and writes to the same counter.

import rateLimit from 'express-rate-limit';
import { RedisStore } from 'rate-limit-redis';
import { createClient } from 'redis';

const redisClient = createClient({ url: process.env.REDIS_URL });
await redisClient.connect();

const limiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 100,
  store: new RedisStore({
    sendCommand: (...args) => redisClient.sendCommand(args),
  }),
});

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

Now all your instances share the same rate limit counters. Scale to 10 servers — the limit still holds. The bot hits a wall regardless of which server it lands on. Beautiful.

What Good Rate Limit Responses Look Like

A 429 response shouldn't leave clients guessing. Use the standardHeaders: true option (already in the examples above) so your response includes headers like:

RateLimit-Limit: 100
RateLimit-Remaining: 0
RateLimit-Reset: 1743610800
Retry-After: 847

Your API clients (whether a frontend app or a third-party developer) can read these headers and back off gracefully instead of hammering you harder in a confused panic. It's good API citizenship.

Common Mistakes to Avoid

Don't rate limit by user ID alone on public routes. An attacker can create infinite accounts. IP-based limiting is your first line of defense.

Don't forget your reverse proxy. If your app sits behind Nginx or a load balancer, req.ip might always be the proxy's IP, not the real client. Fix it with:

app.set('trust proxy', 1); // Trust first proxy

Don't make limits too tight for legit use. A mobile app that refreshes data every 30 seconds across 1,000 users will hit a limit of 100 req/15min at the IP level if your users share a corporate NAT. Know your traffic patterns before setting numbers.

Practical Starting Points

Not sure what limits to set? Here are reasonable defaults for common route types:

Route Type Suggested Limit
Public read API 200 req / 15 min
Authenticated API 500 req / 15 min
Login / Register 5 req / 15 min
Password reset 3 req / hour
Search / expensive ops 20 req / min

These aren't laws — adjust based on your actual user behavior and monitoring data. Start conservative, then loosen limits if legitimate users complain.

The Takeaway

Rate limiting is one of those things that feels optional until the moment it isn't. It takes 15 minutes to add, costs almost nothing to run, and has saved countless APIs from abuse, outages, and surprise cloud bills.

Start with a global limiter using express-rate-limit. Add stricter limits on your auth routes. Move to Redis when you scale beyond a single process. Return helpful headers so clients can handle limits gracefully.

Your API will thank you. Your on-call rotation will thank you. And somewhere, a bot operator will be mildly annoyed — which honestly is the best possible outcome.


Building something interesting with Node.js? Hit me up — I'm always down to talk backend architecture, security patterns, or why your regex is probably dangerous (see my last post).