How to Implement Rate Limiting in a Node.js API with express-rate-limit

Rate limiting a Node.js API is one of those tasks that looks like a five-line job in a tutorial and turns into an incident at 3 a.m. in production. The middleware itself is easy. What breaks is everything around it: a load balancer that hides the real client IP, counters stored in memory while you run four containers, a 429 response with no Retry-After header, or a limit so aggressive that a legitimate mobile app gets locked out.

This guide is the production version of the walkthrough. We go from a working baseline to per-route policies, shared Redis counters, correct proxy configuration, standards-compliant headers, and a rollout plan that does not block real users.

Why rate limiting matters before you ship

Rate limiting bounds how many requests a given subject (an IP address, an API key, a user ID) can make inside a time window. Without it, a single misbehaving client can:

  • Brute-force login, password reset or OTP endpoints
  • Exhaust your database connection pool and take down healthy traffic
  • Scrape your entire catalogue in minutes
  • Run up your bill on paid downstream APIs (email, SMS, LLM providers)
  • Trigger an accidental self-DDoS from a buggy client retry loop

Important framing: express-rate-limit is application-level protection. It is excellent against abuse, credential stuffing and runaway clients. It is not a replacement for a CDN or WAF against volumetric DDoS, because the request still reaches your Node.js process.

server traffic control

Step 1: The baseline setup

Install the middleware:

npm install express-rate-limit

Then wire a global limiter. This example uses ESM and the modern option names (limit instead of the deprecated max):

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

const app = express();

const globalLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutes
  limit: 1000,              // requests per window per key
  standardHeaders: 'draft-8',
  legacyHeaders: false,
  message: { error: 'too_many_requests' },
});

app.use(globalLimiter);

app.get('/api/health', (req, res) => res.json({ ok: true }));

app.listen(3000);

That is the whole “hello world”. Everything below is what separates it from a configuration you can actually deploy.

The options that actually matter

Option What it does Production advice
windowMs Length of the counting window in milliseconds Short windows (1 min) react faster and punish less. Long windows (15 min) are better for auth endpoints.
limit Max requests per window per key Can be a function, so you can give paying tiers a higher ceiling.
keyGenerator Decides what is being limited Default is the client IP. Prefer API key or user ID when authenticated.
store Where counters live Memory by default. Must be Redis (or similar) as soon as you run more than one instance.
standardHeaders Emits IETF RateLimit-* headers Use 'draft-8' on recent versions, or 'draft-7' if you are still on v7.
legacyHeaders Old X-RateLimit-* headers Set to false unless existing clients depend on them.
skip / skipFailedRequests / skipSuccessfulRequests Exclude requests from counting Great for health checks, internal networks and login endpoints.
handler Custom response when the limit is hit Use it to return a consistent JSON error shape and to log the event.

Step 2: Fix the proxy problem first

This is the single most common cause of broken IP-based rate limiting in Node.js. If your app sits behind Nginx, a cloud load balancer, Cloudflare, Heroku, Render or a Kubernetes ingress, then req.ip returns the proxy address unless you tell Express otherwise. The result: every user in the world shares one counter and everyone gets a 429 at the same time. This article covers the same ground in more depth.

// Number of proxies between the client and your app.
// 1 = a single load balancer. 2 = CDN + load balancer.
app.set('trust proxy', 1);

Never use app.set('trust proxy', true) on a public API. It tells Express to trust the leftmost value of X-Forwarded-For, which any client can forge. An attacker simply rotates a fake header value and bypasses your limiter entirely. express-rate-limit even ships validation checks that will warn you in the logs about this exact misconfiguration.

Safer patterns:

  • Set the exact hop count: app.set('trust proxy', 1)
  • Trust only known proxy ranges: app.set('trust proxy', ['loopback', '10.0.0.0/8'])
  • Behind Cloudflare, read the vetted header instead: use req.headers['cf-connecting-ip'] in your keyGenerator

Verify it in 10 seconds

app.get('/api/debug/ip', (req, res) => {
  res.json({ ip: req.ip, xff: req.headers['x-forwarded-for'] });
});

Call it from an external network. If ip is your real public address and not 127.0.0.1 or an internal 10.x address, your trust proxy setting is correct. Remove the route afterwards. Much the same conclusion turns up on akashrajpurohit.com.

server traffic control

Step 3: Per-route limits instead of one global number

A single global limiter is a blunt instrument. A product search endpoint and a password reset endpoint have nothing in common. Build a small factory and apply tailored policies.

// limiters.js
import { rateLimit } from 'express-rate-limit';

const base = {
  standardHeaders: 'draft-8',
  legacyHeaders: false,
};

export const readLimiter = rateLimit({
  ...base,
  windowMs: 60 * 1000,
  limit: 300,
});

export const writeLimiter = rateLimit({
  ...base,
  windowMs: 60 * 1000,
  limit: 60,
});

export const authLimiter = rateLimit({
  ...base,
  windowMs: 15 * 60 * 1000,
  limit: 8,
  skipSuccessfulRequests: true, // only failed logins count
});

export const expensiveLimiter = rateLimit({
  ...base,
  windowMs: 60 * 60 * 1000,
  limit: 20, // PDF export, report generation, AI calls
});

Then mount them where they belong:

app.use('/api/', readLimiter);
app.post('/api/orders', writeLimiter, createOrder);
app.post('/api/auth/login', authLimiter, login);
app.post('/api/auth/forgot-password', authLimiter, forgotPassword);
app.post('/api/reports/export', expensiveLimiter, exportReport);

Order matters. Express runs middleware in registration order, so a route-specific limiter placed after a global one simply adds a second, stricter layer. That layering is intentional and useful: a wide safety net plus a tight guard on sensitive endpoints.

Suggested starting values

Endpoint type Window Limit Key
Public read / search 1 min 120 to 300 IP
Authenticated read 1 min 600 to 1200 API key / user ID
Write operations 1 min 30 to 60 User ID
Login / password reset 15 min 5 to 10 failures IP + account identifier
Email / SMS sending 1 hour 5 to 20 User ID
Heavy export / AI calls 1 hour 10 to 50 Account ID

Treat these as starting points, then tune with real traffic data (see step 7).

Step 4: Choose the right key (and handle IPv6 correctly)

IP is the default, but it is the weakest signal. Mobile carriers and corporate offices put thousands of users behind one address, while a single attacker on IPv6 can rotate through a huge pool of addresses for free.

Rule of thumb: limit by identity when you have one, fall back to IP when you do not.

import { rateLimit, ipKeyGenerator } from 'express-rate-limit';

const apiLimiter = rateLimit({
  windowMs: 60 * 1000,
  limit: (req) => (req.user?.plan === 'pro' ? 1200 : 120),
  standardHeaders: 'draft-8',
  legacyHeaders: false,
  keyGenerator: (req, res) => {
    if (req.user?.id) return `user:${req.user.id}`;
    if (req.get('x-api-key')) return `key:${req.get('x-api-key')}`;
    return `ip:${ipKeyGenerator(req.ip)}`;
  },
});

Two things to note:

  1. Do not return a raw IPv6 address as a key. The built-in ipKeyGenerator helper normalises IPv6 to a /64 subnet so a client cannot escape the limit by changing the last part of its address. Recent versions of the library will throw a validation error if you build an IP key without it.
  2. Namespace your keys (user:, key:, ip:) so an attacker cannot craft an API key value that collides with another subject’s counter.

Double keying for login endpoints

For authentication, limit by IP and by the account being targeted. That stops both a single host hammering many accounts and a distributed botnet hammering one account.

const loginIpLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 20,
  keyGenerator: (req) => `login-ip:${ipKeyGenerator(req.ip)}`,
  skipSuccessfulRequests: true,
});

const loginAccountLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  limit: 5,
  keyGenerator: (req) => `login-acct:${String(req.body?.email || '').toLowerCase()}`,
  skipSuccessfulRequests: true,
});

app.post('/api/auth/login', express.json(), loginIpLimiter, loginAccountLimiter, login);

Step 5: Return a proper 429 response

A good 429 is machine readable. Clients should be able to back off automatically instead of retrying in a tight loop and making things worse.

const limiter = rateLimit({
  windowMs: 60 * 1000,
  limit: 120,
  standardHeaders: 'draft-8',
  legacyHeaders: false,
  handler: (req, res, next, options) => {
    const retryAfter = Math.ceil(options.windowMs / 1000);
    res.setHeader('Retry-After', retryAfter);
    req.log?.warn({ key: req.ip, path: req.path }, 'rate_limit_exceeded');
    res.status(options.statusCode).json({
      error: 'too_many_requests',
      message: 'Rate limit exceeded. Please slow down and retry.',
      retry_after_seconds: retryAfter,
      docs: 'https://geminiweb.net/docs/rate-limits',
    });
  },
});

Which headers clients should read

Header Meaning
RateLimit (draft-8 combined field) Remaining quota and reset time for the active policy
RateLimit-Policy Declares the quota and window so clients can self-throttle
RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset (draft-6 and draft-7) Separate fields used by older drafts
Retry-After Seconds to wait before retrying. Respected by many HTTP clients automatically

If you already have public clients relying on X-RateLimit-Remaining, keep legacyHeaders: true during a deprecation period, then switch it off and document the change.

server traffic control

Step 6: Share counters across instances with Redis

The default memory store keeps counters inside one Node.js process. The moment you scale to 3 pods, a cluster of workers or a serverless platform, each instance keeps its own count. A limit of 100 silently becomes 300. Worse, counters reset on every deploy.

The fix is a shared store. For most teams that means Redis:

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

const redisClient = createClient({ url: process.env.REDIS_URL });
redisClient.on('error', (err) => console.error('redis_error', err));
await redisClient.connect();

export const apiLimiter = rateLimit({
  windowMs: 60 * 1000,
  limit: 300,
  standardHeaders: 'draft-8',
  legacyHeaders: false,
  store: new RedisStore({
    sendCommand: (...args) => redisClient.sendCommand(args),
    prefix: 'rl:api:',
  }),
});

Practical notes:

  • Match major versions. The store interface changed between express-rate-limit v6, v7 and v8. Install the rate-limit-redis release that matches your middleware version, or you will get cryptic runtime errors.
  • Use a prefix per limiter. Different policies must not share counters, and a prefix makes keys easy to inspect with SCAN.
  • Keep Redis close. Every request adds a round trip. Same region, same VPC, ideally sub-millisecond latency.
  • Decide your failure mode. If Redis is down, do you fail open (allow traffic) or fail closed (reject everything)? For public APIs, failing open with a memory fallback is usually the right call.

Fail-open fallback

let redisReady = false;
redisClient.on('ready', () => { redisReady = true; });
redisClient.on('end',   () => { redisReady = false; });

const memoryBackup = rateLimit({ windowMs: 60 * 1000, limit: 300 });

app.use((req, res, next) =>
  redisReady ? apiLimiter(req, res, next) : memoryBackup(req, res, next)
);

Other stores exist if Redis is not in your stack: Memcached, MongoDB, PostgreSQL and Cloudflare-backed options are all available in the ecosystem. The configuration pattern is identical: swap the store.

Step 7: Roll it out without blocking legitimate users

The riskiest part of rate limiting is the first deploy. Here is a safe sequence.

  1. Run in shadow mode. Keep counting but never reject, and log what would have been blocked.
  2. Collect data for one to two weeks. Look at the 99th percentile of requests per minute per key, not the average.
  3. Set the limit well above the p99 of real users, typically 3 to 5 times higher.
  4. Enable enforcement on low-risk routes first, then on writes, then globally.
  5. Alert on the 429 rate. A sudden spike usually means a bad limit or a broken client, not an attack.

Shadow mode in practice

const shadow = process.env.RATE_LIMIT_MODE === 'shadow';

const limiter = rateLimit({
  windowMs: 60 * 1000,
  limit: 120,
  standardHeaders: 'draft-8',
  legacyHeaders: false,
  handler: (req, res, next, options) => {
    if (shadow) {
      console.warn('rate_limit_would_block', { ip: req.ip, path: req.path });
      return next();
    }
    res.status(options.statusCode).json({ error: 'too_many_requests' });
  },
});

Allowlists and exemptions

const INTERNAL = new Set((process.env.ALLOWLIST_IPS || '').split(',').filter(Boolean));

const limiter = rateLimit({
  windowMs: 60 * 1000,
  limit: 120,
  skip: (req) =>
    req.path === '/api/health' ||
    req.path === '/metrics' ||
    INTERNAL.has(req.ip) ||
    req.user?.role === 'service-account',
});

Common exemptions worth configuring from day one:

  • Health checks and readiness probes (otherwise your orchestrator can rate-limit itself)
  • Monitoring and uptime agents
  • Internal service-to-service calls from your own VPC
  • Webhook receivers from trusted providers, which legitimately burst
  • OPTIONS preflight requests, which browsers send automatically

Also consider pairing the limiter with express-slow-down. Instead of a hard wall, it adds progressive delay to each request past a threshold, which degrades aggressive clients gracefully while real users barely notice.

Step 8: Test it before your users do

A quick shell check:

for i in $(seq 1 30); do
  curl -s -o /dev/null -w "%{http_code} " http://localhost:3000/api/items
done
echo

Inspect the headers on a single call:

curl -i http://localhost:3000/api/items | grep -i 'ratelimit\|retry-after'

For load behaviour, use autocannon:

npx autocannon -c 50 -d 10 http://localhost:3000/api/items

And an automated regression test so nobody weakens the policy by accident:

import request from 'supertest';
import app from '../src/app.js';

test('returns 429 after the limit is exceeded', async () => {
  const limit = 5;
  for (let i = 0; i < limit; i++) {
    await request(app).get('/api/items').expect(200);
  }
  const res = await request(app).get('/api/items').expect(429);
  expect(res.headers['retry-after']).toBeDefined();
});

Remember to reset the store between tests, or to inject a dedicated test limiter with tiny values.

server traffic control

express-rate-limit vs the alternatives

Solution Algorithm Best for Trade-off
express-rate-limit Fixed window counter Most Express APIs, fastest path to production Burst possible at window boundaries
rate-limiter-flexible Token bucket, block duration, insurance limiters Complex multi-tier quotas, non-Express stacks, queueing More configuration, steeper learning curve
express-slow-down Progressive delay Softening abuse without hard rejections Holds connections open, use alongside a hard limit
CDN / WAF / API gateway Edge rules Volumetric attacks, blocking before your origin No access to app-level identity such as user ID or plan

The strongest setup is layered: edge rules for raw volume, express-rate-limit for per-identity business rules inside the app.

Production checklist

  • ☐ trust proxy set to an exact hop count or known subnets, never true
  • ☐ req.ip verified from an external network
  • ☐ Redis (or another shared store) configured for every non single-instance deployment
  • ☐ Separate limiters for read, write, auth and expensive endpoints
  • ☐ Keys namespaced and IPv6 normalised with ipKeyGenerator
  • ☐ standardHeaders enabled, legacyHeaders disabled, Retry-After present on 429
  • ☐ Health checks, probes and internal services exempted via skip
  • ☐ 429 events logged with key, route and user agent
  • ☐ Alert configured on an abnormal 429 rate
  • ☐ Limits documented publicly for API consumers
  • ☐ Automated test covering the 429 path

Frequently asked questions

Does express-rate-limit work without Redis?

Yes. The default memory store is fine for a single process: a small VPS, a prototype, or a service that never scales horizontally. As soon as you run multiple instances, workers or serverless functions, switch to a shared store, otherwise your effective limit is multiplied by the number of instances.

What status code and headers should a rate-limited response use?

Use HTTP 429 Too Many Requests with a Retry-After header and the IETF RateLimit headers. Returning 403 or 503 instead confuses clients and breaks automatic retry logic in most HTTP libraries.

Why do all my users get rate limited at the same time?

Almost always a proxy configuration issue. Express is seeing the load balancer IP for every request, so all traffic shares a single counter. Set app.set('trust proxy', 1) (adjusting the number to your real hop count) and confirm that req.ip returns genuine client addresses.

Should I rate limit by IP or by API key?

By API key or user ID whenever the request is authenticated, because it is a far more accurate identity and it survives IP changes on mobile networks. Keep IP-based limiting for anonymous traffic and as a second layer on login endpoints. gitconnected.com goes into the numbers.

How do I pick the right limit value?

Measure before you decide. Run the limiter in shadow mode, record the 99th percentile of requests per minute per key for real users, then set your limit 3 to 5 times above it. Tighten later if abuse appears. Starting too strict generates support tickets and damaged trust.

Can rate limiting be bypassed?

IP-based limiting can be diluted by proxy pools and IPv6 rotation, and it can be bypassed completely if you blindly trust a forgeable X-Forwarded-For header. Mitigate with correct proxy trust settings, IPv6 subnet normalisation, identity-based keys, and an edge layer (CDN or WAF) in front of your origin.

Does rate limiting slow down my API?

With the memory store the overhead is negligible. With Redis you add one network round trip per request, typically well under a millisecond in the same region. That is an acceptable price compared to an outage caused by a single abusive client.

Final thoughts

Rate limiting a Node.js API properly is less about the middleware and more about the details around it: trusting the right proxy, choosing a meaningful key, sharing counters across instances, and shipping a 429 that clients can understand. Get those four right and express-rate-limit will quietly absorb the kind of traffic that would otherwise wake you up at night.

Need help hardening an existing Express or Node.js API? The team at geminiweb.net designs and audits production API infrastructure, from rate limiting and caching to observability and scaling.

Search

Recent Blog

  • All Post
  • Email Marketing
  • Responsive Website
  • SEO
  • Social Media Marketing
  • Web Design
  • Web Development

Subscribe

You have been successfully Subscribed! Ops! Something went wrong, please try again.

Company Name

Gemini Web

Company Address

3444 Hall Valley Drive, Davy, WV 24828 USA

Company Email

[email protected]

Copyright © 2022 Gemini Web. All Rights Reserved.