What is Rate Limiting?
Rate limiting is a technique used to control the number of requests a client can make to an API within a specified time window. It acts as a gatekeeper that monitors incoming traffic and enforces predefined thresholds. When a client exceeds the allowed number of requests, the API responds with an HTTP 429 (Too Many Requests) status code and typically includes information about when the client can retry.
At its core, rate limiting relies on tracking two pieces of data per client (usually identified by IP address or API key): the number of requests made and the time window in which those requests occurred. Common algorithms include the fixed window counter, sliding window log, sliding window counter, token bucket, and leaky bucket — each with different trade-offs between accuracy, memory usage, and implementation complexity.
Why Rate Limiting Matters
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Implementing rate limiting is not just a nice-to-have; it is a critical defensive layer for any production API. Here are the key reasons:
- Preventing Denial-of-Service (DoS) attacks: Without rate limits, a malicious actor can flood your API with thousands of requests per second, overwhelming your server and denying service to legitimate users.
- Protecting infrastructure costs: Each API request consumes CPU, memory, database connections, and potentially third-party API credits. Rate limiting prevents runaway costs from abusive or buggy clients.
- Ensuring fair usage: In multi-tenant systems or public APIs, rate limiting ensures that one aggressive client does not starve others of resources.
- Mitigating brute-force attacks: Login endpoints, password reset forms, and token generation routes benefit enormously from strict rate limits to slow down automated guessing attacks.
- Improving overall stability: By capping request throughput, you prevent cascading failures where a sudden traffic spike exhausts database connection pools or memory, bringing down the entire service.
How to Implement Rate Limiting in a Node.js API
There are several approaches to implementing rate limiting in Node.js, ranging from simple in-memory counters suitable for single-server deployments to distributed Redis-based solutions that work across multiple API instances. Below we cover each approach with complete, production-ready code examples.
Approach 1: Basic In-Memory Rate Limiter (Fixed Window)
The simplest implementation stores request counts in a plain JavaScript Map. This works well for development or single-instance deployments where you don't need persistence across restarts. The following example implements a fixed window counter that resets after each interval.
Create a file called rateLimiter.js:
// rateLimiter.js — Basic in-memory fixed-window rate limiter
const rateLimiter = (maxRequests, windowMs) => {
// Store: key (client IP or ID) -> { count, resetTime }
const clients = new Map();
// Clean up expired entries every 60 seconds to prevent memory leaks
const cleanupInterval = setInterval(() => {
const now = Date.now();
for (const [key, data] of clients.entries()) {
if (data.resetTime <= now) {
clients.delete(key);
}
}
}, 60000);
// Allow the cleanup interval to not block process exit
if (cleanupInterval.unref) {
cleanupInterval.unref();
}
return (req, res, next) => {
const clientKey = req.ip || req.headers['x-forwarded-for'] || 'unknown';
const now = Date.now();
let clientData = clients.get(clientKey);
// If no record exists or window has expired, create/reset
if (!clientData || clientData.resetTime <= now) {
clientData = {
count: 1,
resetTime: now + windowMs,
};
clients.set(clientKey, clientData);
return next();
}
// Check if client has exceeded the limit
if (clientData.count >= maxRequests) {
const remainingMs = clientData.resetTime - now;
res.set('Retry-After', Math.ceil(remainingMs / 1000));
res.set('X-RateLimit-Limit', maxRequests);
res.set('X-RateLimit-Remaining', 0);
res.set('X-RateLimit-Reset', Math.ceil(clientData.resetTime / 1000));
return res.status(429).json({
error: 'Too Many Requests',
message: `Rate limit exceeded. Try again in ${Math.ceil(remainingMs / 1000)} seconds.`,
});
}
// Increment count and proceed
clientData.count++;
res.set('X-RateLimit-Limit', maxRequests);
res.set('X-RateLimit-Remaining', maxRequests - clientData.count);
res.set('X-RateLimit-Reset', Math.ceil(clientData.resetTime / 1000));
next();
};
};
module.exports = rateLimiter;
Now integrate it into your Express application:
// app.js — Express application with in-memory rate limiter
const express = require('express');
const rateLimiter = require('./rateLimiter');
const app = express();
const PORT = 3000;
// Apply global rate limit: 100 requests per 15 minutes
app.use(rateLimiter(100, 15 * 60 * 1000));
// Stricter limit on sensitive routes: 5 requests per minute for login
const strictLimiter = rateLimiter(5, 60 * 1000);
app.use('/api/login', strictLimiter);
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
app.post('/api/login', (req, res) => {
// Authentication logic here
res.json({ message: 'Login endpoint (rate limited)' });
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
This implementation works well for single-instance deployments. However, it has limitations: the counter data is lost on server restart, and it does not work across multiple Node.js processes (like when using PM2 cluster mode or Kubernetes pods). For production multi-instance setups, you need a shared data store.
Approach 2: Using express-rate-limit Middleware
The express-rate-limit package is the most popular, battle-tested rate limiting middleware for Express. It handles edge cases like request header spoofing, provides configurable response handling, and supports multiple storage backends out of the box.
Install it first:
npm install express-rate-limit
Basic usage in your Express app:
// app.js — Using express-rate-limit middleware
const express = require('express');
const rateLimit = require('express-rate-limit');
const app = express();
// Global limiter: 200 requests per 15 minutes per IP
const globalLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 200, // limit each IP to 200 requests per window
standardHeaders: true, // Return rate limit info in the RateLimit-* headers
legacyHeaders: false, // Disable the X-RateLimit-* headers
message: {
error: 'Too Many Requests',
message: 'You have exceeded the request limit. Please try again later.',
},
statusCode: 429,
});
// Auth limiter: 10 requests per minute for login route
const authLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 10,
standardHeaders: true,
legacyHeaders: false,
skipSuccessfulRequests: false, // Count all requests, not just failed ones
keyGenerator: (req) => {
// Use both IP and a user identifier if available for finer granularity
const userKey = req.body?.username || req.ip;
return `login_${userKey}`;
},
handler: (req, res) => {
res.status(429).json({
error: 'Too Many Login Attempts',
message: 'Please wait one minute before trying again.',
retryAfter: Math.ceil(req.rateLimit.resetTime / 1000),
});
},
});
app.use(globalLimiter);
app.use('/api/auth/login', authLimiter);
app.get('/api/data', (req, res) => {
res.json({ data: 'Some valuable data' });
});
app.post('/api/auth/login', (req, res) => {
// Authentication logic
res.json({ token: 'jwt-token-example' });
});
app.listen(3000, () => console.log('Server on port 3000'));
The express-rate-limit package provides several configuration options worth highlighting:
- windowMs: The time window in milliseconds during which the max request count applies.
- max: Maximum number of requests allowed per window per client.
- keyGenerator: A function that determines what constitutes a unique client. By default it uses
req.ip, but you can combine IP with API keys or user IDs. - handler: Custom response handler for rate-limited requests — useful for returning structured JSON errors.
- standardHeaders / legacyHeaders: Controls whether rate limit information is sent via modern
RateLimit-*headers or legacyX-RateLimit-*headers. - skip: A function to conditionally skip rate limiting for certain requests (e.g., internal health checks).
Approach 3: Redis-Based Rate Limiting for Distributed Systems
When your Node.js API runs across multiple instances — behind a load balancer, in a Kubernetes cluster, or using PM2 cluster mode — in-memory rate limiters fail because each instance maintains its own separate counter. Redis provides a centralized, high-performance data store perfect for coordinating rate limiting across all instances.
The sliding window algorithm using sorted sets in Redis is particularly effective. It provides accurate counting without the "burst at boundary" problem of fixed windows. Here is a complete implementation:
Install the required Redis client:
npm install ioredis
Create redisRateLimiter.js:
// redisRateLimiter.js — Sliding window rate limiter using Redis sorted sets
const Redis = require('ioredis');
// Create Redis connection (supports connection string or separate options)
const createRedisClient = (redisUrl = 'redis://localhost:6379') => {
const client = new Redis(redisUrl, {
maxRetriesPerRequest: 3,
retryStrategy(times) {
if (times > 10) return null; // Stop retrying after 10 attempts
return Math.min(times * 50, 2000); // Exponential backoff, max 2s
},
enableOfflineQueue: false, // Fail fast if Redis is unreachable
});
client.on('error', (err) => {
console.error('Redis connection error:', err.message);
});
client.on('ready', () => {
console.log('Redis rate limiter connected');
});
return client;
};
/**
* Sliding window rate limiter using Redis sorted sets.
* @param {Object} redisClient - ioredis client instance
* @param {number} maxRequests - Max requests allowed in the window
* @param {number} windowMs - Window duration in milliseconds
* @param {Function} keyGenerator - Function to generate the Redis key from the request
* @returns {Function} Express middleware
*/
const redisSlidingWindowLimiter = (redisClient, maxRequests, windowMs, keyGenerator) => {
return async (req, res, next) => {
try {
const clientKey = keyGenerator ? keyGenerator(req) : req.ip;
const redisKey = `rate:${clientKey}`;
const now = Date.now();
const windowStart = now - windowMs;
// Use a Lua script for atomicity: remove expired entries and count remaining
// This runs atomically on the Redis server, avoiding race conditions
const luaScript = `
-- Remove all entries older than the window
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', ARGV[1])
-- Count the remaining entries (requests in the current window)
local count = redis.call('ZCARD', KEYS[1])
-- Add the current request with a unique score (timestamp + microsecond offset)
-- We add a small random component to handle multiple requests in the same millisecond
local score = ARGV[2] + (math.random(0, 999) / 1000)
redis.call('ZADD', KEYS[1], score, ARGV[3])
-- Set TTL on the key to clean up memory automatically (window + buffer)
redis.call('PEXPIRE', KEYS[1], ARGV[4])
return count
`;
// Generate a unique member value for this request
const uniqueMember = `${now}-${Math.random().toString(36).substring(2, 10)}`;
const result = await redisClient.eval(
luaScript,
1, // Number of keys
redisKey, // KEYS[1]
windowStart, // ARGV[1] — cutoff timestamp
now, // ARGV[2] — score (timestamp)
uniqueMember, // ARGV[3] — unique member identifier
windowMs + 1000 // ARGV[4] — TTL in milliseconds (window + 1s buffer)
);
const currentCount = result;
// Set rate limit headers
const remaining = Math.max(0, maxRequests - currentCount);
res.set('RateLimit-Limit', maxRequests);
res.set('RateLimit-Remaining', remaining);
res.set('RateLimit-Reset', Math.ceil((now + windowMs) / 1000));
// If the count (before adding the current request) is >= maxRequests, reject
// Note: we added the current request *after* counting, so count is the number
// of previous requests in the window. If count >= maxRequests, reject.
if (currentCount >= maxRequests) {
// Calculate retry-after
const oldestRequest = await redisClient.zrange(redisKey, 0, 0, 'WITHSCORES');
let retryAfterMs = windowMs;
if (oldestRequest.length >= 2) {
const oldestScore = parseFloat(oldestRequest[1]);
retryAfterMs = Math.max(0, Math.ceil(oldestScore + windowMs - now));
}
res.set('Retry-After', Math.ceil(retryAfterMs / 1000));
return res.status(429).json({
error: 'Too Many Requests',
message: `Rate limit exceeded. Retry after ${Math.ceil(retryAfterMs / 1000)} seconds.`,
limit: maxRequests,
remaining: 0,
});
}
// Update remaining after adding the current request
res.set('RateLimit-Remaining', Math.max(0, maxRequests - currentCount - 1));
next();
} catch (err) {
console.error('Redis rate limiter error:', err.message);
// Fail open: allow the request through if Redis is unavailable
// You may choose to fail closed depending on your security requirements
next();
}
};
};
module.exports = { createRedisClient, redisSlidingWindowLimiter };
Now integrate the Redis rate limiter into your application:
// app.js — Express app with Redis-based distributed rate limiting
const express = require('express');
const { createRedisClient, redisSlidingWindowLimiter } = require('./redisRateLimiter');
const app = express();
const PORT = 3000;
// Initialize Redis client
const redisClient = createRedisClient(process.env.REDIS_URL || 'redis://localhost:6379');
// Global rate limit: 500 requests per 15 minutes per IP
const globalLimiter = redisSlidingWindowLimiter(
redisClient,
500,
15 * 60 * 1000,
(req) => req.ip
);
// API key-based rate limit: 1000 requests per hour per API key
const apiKeyLimiter = redisSlidingWindowLimiter(
redisClient,
1000,
60 * 60 * 1000,
(req) => req.headers['x-api-key'] || req.ip // Fall back to IP if no API key
);
// Sensitive endpoint limiter: 5 requests per minute
const sensitiveLimiter = redisSlidingWindowLimiter(
redisClient,
5,
60 * 1000,
(req) => `sensitive_${req.ip}`
);
app.use(globalLimiter);
app.use('/api/v1', apiKeyLimiter);
app.use('/api/admin', sensitiveLimiter);
app.get('/api/health', (req, res) => {
res.json({ status: 'ok' });
});
app.get('/api/v1/users', (req, res) => {
res.json([{ id: 1, name: 'Alice' }]);
});
app.post('/api/admin/config', (req, res) => {
res.json({ updated: true });
});
// Graceful shutdown
process.on('SIGTERM', async () => {
console.log('Shutting down...');
await redisClient.quit();
process.exit(0);
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
The Redis-based approach offers several advantages: it works seamlessly across any number of Node.js instances, persists rate limit counters across server restarts, and uses atomic Lua scripts to prevent race conditions. The sorted set implementation provides accurate sliding window behavior — a request made at 12:00:00 and another at 12:14:59 both count against the same 15-minute window, but at 12:15:01 the first request expires precisely when it should.
Approach 4: Token Bucket Algorithm
The token bucket algorithm is widely used by major API providers because it allows for burst traffic while maintaining a steady long-term rate. Each client has a bucket that holds tokens. Tokens are added at a constant rate (the refill rate) up to a maximum capacity (the bucket size). Each request consumes one token. If tokens are available, the request proceeds; if the bucket is empty, the request is rejected. This allows clients to burst up to the bucket capacity while still enforcing a long-term average rate.
Here is a Redis-backed token bucket implementation:
// tokenBucketLimiter.js — Token bucket rate limiter using Redis
const Redis = require('ioredis');
/**
* Token bucket rate limiter.
* @param {Object} redisClient - ioredis client
* @param {number} capacity - Maximum bucket size (burst capacity)
* @param {number} refillRate - Tokens added per second (sustained rate)
* @param {Function} keyGenerator - Function to generate client key from request
* @returns {Function} Express middleware
*/
const tokenBucketLimiter = (redisClient, capacity, refillRate, keyGenerator) => {
return async (req, res, next) => {
try {
const clientKey = keyGenerator ? keyGenerator(req) : req.ip;
const redisKey = `bucket:${clientKey}`;
const now = Date.now();
const luaScript = `
-- Get current bucket state
local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or ARGV[1] -- default to capacity if new
local lastRefill = tonumber(bucket[2]) or ARGV[2]
-- Calculate time since last refill and tokens to add
local elapsedMs = math.max(0, ARGV[2] - lastRefill)
local tokensToAdd = math.floor(elapsedMs * ARGV[3] / 1000)
tokens = math.min(ARGV[1], tokens + tokensToAdd)
local allowed = 0
local newTokens = tokens
if tokens >= 1 then
allowed = 1
newTokens = tokens - 1
end
-- Update bucket state
redis.call('HSET', KEYS[1], 'tokens', newTokens, 'last_refill', ARGV[2])
-- Set TTL to clean up inactive buckets (capacity / refillRate * 2 + 60s buffer)
local ttlSeconds = math.ceil((ARGV[1] / ARGV[3]) * 2) + 60
redis.call('EXPIRE', KEYS[1], ttlSeconds)
return {allowed, newTokens}
`;
const result = await redisClient.eval(
luaScript,
1,
redisKey,
capacity, // ARGV[1] — bucket capacity
now, // ARGV[2] — current timestamp
refillRate // ARGV[3] — tokens per second refill rate
);
// result is [allowed, newTokens] — Lua table converted to array
const allowed = result[0];
const tokensLeft = result[1];
res.set('X-RateLimit-Limit', capacity);
res.set('X-RateLimit-Remaining', Math.floor(tokensLeft));
res.set('X-RateLimit-Bucket-Capacity', capacity);
res.set('X-RateLimit-Refill-Rate', refillRate);
if (!allowed) {
// Calculate time until next token is available
const secondsUntilToken = Math.ceil(1 / refillRate);
res.set('Retry-After', secondsUntilToken);
return res.status(429).json({
error: 'Too Many Requests',
message: `Rate limit exceeded. Retry after ${secondsUntilToken} seconds.`,
bucketCapacity: capacity,
tokensRemaining: 0,
});
}
res.set('X-RateLimit-Remaining', Math.floor(tokensLeft));
next();
} catch (err) {
console.error('Token bucket error:', err.message);
next(); // Fail open
}
};
};
module.exports = tokenBucketLimiter;
Using the token bucket limiter in your app:
const express = require('express');
const Redis = require('ioredis');
const tokenBucketLimiter = require('./tokenBucketLimiter');
const app = express();
const redis = new Redis('redis://localhost:6379');
// Allow bursts of up to 50 requests, with a sustained rate of 10 requests per second
const apiLimiter = tokenBucketLimiter(
redis,
50, // bucket capacity (burst size)
10, // refill rate (tokens per second)
(req) => req.headers['x-api-key'] || req.ip
);
app.use('/api/', apiLimiter);
app.get('/api/data', (req, res) => {
res.json({ message: 'Token bucket rate limited endpoint' });
});
app.listen(3000);
The token bucket is ideal when your API clients have legitimate burst patterns — for example, a mobile app that makes several requests on startup but then idles. The bucket capacity allows that burst to succeed while the refill rate ensures long-term average usage stays within limits.
Rate Limiting Best Practices
Implementing rate limiting effectively requires more than just dropping in middleware. Consider these best practices to build a robust, user-friendly system:
- Return informative headers: Always include
RateLimit-Limit,RateLimit-Remaining,RateLimit-Reset, andRetry-Afterheaders. This allows well-behaved clients to self-regulate and avoid hitting limits unnecessarily. - Use structured error responses: Return consistent JSON error bodies with clear messages, the limit value, and the retry-after duration. This makes client integration predictable.
- Fail open vs. fail closed: Decide whether to allow requests through when your rate limiting backend (like Redis) is unavailable. For user-facing APIs, failing open prevents outages but risks abuse. For security-critical endpoints (login, password reset), consider failing closed or using a fallback in-memory limiter.
- Tier your limits: Apply different limits based on authentication status. Anonymous IP-based requests might get 100 requests per hour, while authenticated API key users get 1000 requests per hour. Premium tiers can have even higher limits.
- Rate limit by logical identity, not just IP: For authenticated endpoints, use the user ID or API key as the rate limit key. This prevents one user behind a NAT from affecting others and stops attackers from rotating IPs.
- Monitor and alert: Track rate limit hits via metrics (Prometheus, Datadog, CloudWatch). A sudden spike in 429 responses can indicate an attack, a misconfigured client, or that your limits are too restrictive for normal usage patterns.
- Test your limits: Use tools like
artillery,k6, orwrkto simulate traffic and verify that your rate limiter triggers correctly and that your application handles 429 responses gracefully under load. - Consider distributed consistency: When using Redis, be aware of network partitions. If your Redis cluster splits, rate limit counters may diverge. For extremely high accuracy requirements, consider using sticky sessions or consistent hashing to route the same client to the same rate limiter instance.
- Document your limits publicly: Publish your rate limits in API documentation so developers can design their clients accordingly. Include information about how to request higher limits for legitimate use cases.
Conclusion
Rate limiting is an essential defensive layer for any Node.js API, protecting against abuse, ensuring fair resource allocation, and maintaining system stability. Whether you start with a simple in-memory counter for a small project, adopt the robust express-rate-limit middleware for a production single-instance deployment, or implement a Redis-backed sliding window or token bucket for a distributed microservice architecture, the core principle remains the same: measure, enforce, and communicate limits clearly. By following the implementation patterns and best practices outlined in this tutorial, you can build a rate limiting system that is accurate, performant, and respectful of legitimate API consumers while keeping malicious traffic at bay.