express-multi-limiter
v2.0.0
Published
A high-performance, multi-algorithm rate limiter and WAF for Express
Maintainers
Readme
express-multi-limiter 🚀🛡️
A high-performance, enterprise-grade, multi-algorithm rate limiter and lightweight Web Application Firewall (WAF) for Express.js.
While most rate limiters offer a single basic counter, express-multi-limiter gives you the choice of five different mathematical rate-limiting algorithms, supports distributed databases (like Redis), features a unique Penalty Box system to automatically ban abusive users, and implements Strict Mutex Locking to prevent concurrency race conditions.
📑 Table of Contents
- Why express-multi-limiter?
- Core Features
- Installation
- Quick Start (In-Memory)
- Advanced: Redis & Distributed Clusters
- Configuration API Reference
- The 5 Algorithms (Deep Dive)
- The Penalty Box (WAF Abuse Prevention)
- Dynamic Tiering & Cost-Based Limiting
- Event Telemetry & Monitoring
- RFC 9333 Standard Headers
- Architecture: Solving Race Conditions (Mutex)
- Comparison with Competitors
- Contributing
- License
🤔 Why express-multi-limiter?
Modern APIs face diverse traffic patterns. A /login route needs strict burst protection, while an /export-data route needs steady, smooth rate limiting. Basic counters fall short.
Furthermore, under high load, standard Node.js rate limiters suffer from Race Conditions. If two requests from the same IP hit the server at the exact same millisecond, they both read a count of 5, both increment to 6, and write 6. One request bypasses the limit entirely.
express-multi-limiter solves this by introducing Atomic Promise-based Mutex Queues in memory, and native support for Lua-scripted atomic execution in distributed stores like Redis.
✨ Core Features
- 5 Rate Limiting Algorithms: Fixed Window, Sliding Window Counter, Sliding Window Log, Token Bucket, and Leaky Bucket.
- The Penalty Box (WAF): Automatically send repeat offenders to "jail," immediately rejecting their requests with a
403 Forbiddenstatus for a set duration. - True Atomic Execution (No Race Conditions): Uses Promise Mutex chaining to guarantee chronological state updates under high concurrency.
- Distributed Store Ready: Plugs seamlessly into Redis or Memcached for Kubernetes/PM2 multi-server clusters.
- Dynamic / Tiered Limits: Apply different limits to different users (e.g., Free vs. Premium tiers) dynamically per request.
- Cost-Based Limiting: Assign higher token "costs" to resource-intensive routes.
- IP Allowlist & Blocklist: Immediately block malicious IPs or bypass limits for internal microservices using
O(1)Set lookups. - Graceful Degradation (Fail-Open): If your Redis server crashes, the limiter can temporarily allow traffic so your app doesn't go offline.
- RFC 9333 Compliance: Injects modern, standardized rate-limit HTTP headers.
📦 Installation
Install via your preferred package manager:
# npm
npm install express-multi-limiter🚀 Quick Start (In-Memory)
By default, the limiter runs in memory. This is perfect for single-server setups or local development.
const express = require('express');
const { createLimiter } = require('express-multi-limiter');
const app = express();
// Create the limiter middleware
const apiLimiter = createLimiter({
algorithm: 'sliding-counter', // Smoothly handles traffic spikes
windowMs: 60 * 1000, // 1 minute time window
limit: 100, // 100 requests per minute
// Enable the Penalty Box to punish abusers
penaltyBox: {
enabled: true,
maxViolations: 3, // If they hit the limit 3 times...
jailTimeMs: 15 * 60 * 1000 // ...ban them entirely for 15 minutes
}
});
// Apply to all /api routes
app.use('/api/', apiLimiter);
app.get('/api/data', (req, res) => {
res.json({ message: "Success!" });
});
app.listen(3000, () => console.log('Server running on port 3000'));🌍 Advanced: Redis & Distributed Clusters
If you are running multiple instances of your Node.js app (e.g., Kubernetes, AWS ECS, PM2), you must share the rate-limit state across all servers.
Here is how you connect express-multi-limiter to Redis using the official redis npm package:
const express = require('express');
const { createClient } = require('redis');
const { createLimiter } = require('express-multi-limiter');
const app = express();
// 1. Initialize Redis Client
const redisClient = createClient({ url: 'redis://localhost:6379' });
redisClient.connect().catch(console.error);
// 2. Create an Atomic Store Adapter
const redisStore = {
// Standard getters and setters
get: async (key) => {
const data = await redisClient.get(key);
return data ? JSON.parse(data) : undefined;
},
set: async (key, value) => {
const ttlSeconds = value.expiresAt
? Math.max(1, Math.ceil((value.expiresAt - Date.now()) / 1000))
: 60;
await redisClient.setEx(key, ttlSeconds, JSON.stringify(value));
},
delete: async (key) => await redisClient.del(key),
// Advanced: Implement atomic execution using Redis MULTI/EXEC or Lua
// This prevents race conditions across different physical servers
executeAtomic: async (key, logicFn) => {
// Simple example locking mechanism (Use robust packages like Redlock in production)
const lockKey = `lock:${key}`;
const acquired = await redisClient.set(lockKey, 'locked', { NX: true, EX: 2 });
if (!acquired) {
// Wait and retry logic goes here
throw new Error("Resource busy");
}
try {
const data = await redisClient.get(key);
const current = data ? JSON.parse(data) : undefined;
const { state, result } = logicFn(current); // Sync execution
if (state !== undefined) {
const ttl = state.expiresAt ? Math.ceil((state.expiresAt - Date.now()) / 1000) : 60;
await redisClient.setEx(key, ttl, JSON.stringify(state));
}
return result;
} finally {
await redisClient.del(lockKey);
}
}
};
// 3. Pass the store to the limiter
const distributedLimiter = createLimiter({
store: redisStore,
failOpen: true, // If Redis goes offline, allow traffic to pass (Graceful Degradation)
algorithm: 'token-bucket',
limit: 50,
windowMs: 60000
});
app.use(distributedLimiter);⚙️ Configuration API Reference
The createLimiter(config) function accepts the following configuration options:
| Property | Type | Default | Description |
|----------|------|---------|-------------|
| algorithm | string | 'sliding-counter' | The mathematical rate-limiting engine. Supported values are fixed-window, sliding-counter, sliding-log, token-bucket, and leaky-bucket. |
| limit | number \| Function | 10 | Maximum requests allowed in the configured time window. Can also be an asynchronous function for dynamic limits. |
| windowMs | number \| Function | 60000 | The time window, in milliseconds. Can also be an asynchronous function. |
| cost | number \| Function | 1 | Token cost per request. Useful for assigning higher costs to resource-intensive endpoints. |
| store | Object | MemoryStore | Database adapter implementing the required storage interface (get, set, delete, executeAtomic). |
| penaltyBox | Object | {} | Configuration object for the built-in WAF Penalty Box system. |
| allowList | Array<string> | [] | Exact IP addresses that bypass rate limiting entirely. Lookups are performed in O(1) using a Set. |
| blockList | Array<string> | [] | Exact IP addresses that are immediately rejected with 403 Forbidden before any rate-limiting logic executes. |
| failOpen | boolean | true | If the backing store throws an error (e.g., Redis outage), requests are allowed instead of failing the API. |
| keyGenerator | Function | (req) => req.ip | Generates the unique identifier used for rate limiting (e.g., req.user.id, API key, or IP address). |
| skip | Function | () => false | Return true to bypass rate limiting for a request (e.g., health checks or internal routes). |
| handler | Function | Default 429 Handler | Customizes the HTTP response returned when a request exceeds the configured limit. |
| standardHeaders | boolean | true | Enables or disables injection of RFC 9333 rate limit headers (RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset). |
🧠 The 5 Algorithms (Deep Dive)
Choosing the right algorithm is critical for your specific endpoint's health.
- Fixed Window (fixed-window) The simplest and most memory-efficient. Groups requests into rigid blocks of time (e.g., exactly 12:00:00 to 12:01:00).
Pros: Extremely fast, low memory footprint.
Cons: Susceptible to the "Edge Burst" problem. A user can send 100 requests at 12:00:59, and another 100 at 12:01:01, effectively pushing 200 requests in 2 seconds.
- Sliding Window Counter (sliding-counter) The best all-rounder. Smoothes out traffic spikes by calculating an overlap percentage between the previous time window and the current one.
Pros: Highly memory efficient. Fixes the Edge Burst problem.
Cons: Assumes a perfectly uniform distribution of requests in the previous window.
Best for: Standard REST API limits (e.g., 100 req/min on general endpoints).
- Sliding Window Log (sliding-log) The most mathematically precise limit. Logs the exact timestamp of every single request. Our implementation uses O(k) array slicing optimizations for high performance.
Pros: 100% accurate rate limiting down to the millisecond.
Cons: Expensive memory footprint. Storing 10,000 timestamps for an active IP takes up RAM/Redis space.
Best for: Extremely strict endpoints like /sms/send or /payment/process.
- Token Bucket (token-bucket) Uses floating-point precision to slowly add tokens to a bucket over time. Requests remove tokens.
Pros: Allows for immediate, massive bursts of traffic up to the limit, but forces a steady downshift afterward.
Cons: Slightly more complex to tune refill rates.
Best for: Third-party Webhook ingestion or Public APIs where clients batch requests.
- Leaky Bucket (leaky-bucket) Requests are dumped into a bucket. The bucket leaks them into the server at a perfectly strict, continuous floating-point rate.
Pros: Guarantees your downstream database will never experience a burst spike. Forces extreme traffic smoothing.
Cons: Users experiencing spikes will immediately get 429s instead of processing quickly.
Best for: Protecting fragile legacy systems or heavy background-worker queues.
🚔 The Penalty Box (WAF Abuse Prevention)
Normal rate limiters tell abusers to "slow down." The Penalty Box treats them as malicious actors.
If a user repeatedly ignores your 429 Too Many Requests responses, they are placed in the Penalty Box. While in the box, their requests do not even touch the algorithms; they are instantly rejected with a 403 Forbidden.
Example
const secureLimiter = createLimiter({
limit: 50,
windowMs: 60000,
penaltyBox: {
enabled: true,
maxViolations: 3, // Hitting 429 three times triggers the box
jailTimeMs: 60 * 60 * 1000 // Jail time: 1 Hour
}
});🎭 Dynamic Tiering & Cost-Based Limiting
You can pass asynchronous functions to configuration properties to evaluate limits on the fly based on the request context.
1. Subscription Tiers
const tierLimiter = createLimiter({
algorithm: 'sliding-counter',
windowMs: 60000,
limit: async (req) => {
if (!req.user) return 10; // Guest: 10/min
if (req.user.plan === 'premium') return 1000; // Premium: 1000/min
return 100; // Free: 100/min
}
});2. Expensive Route Costs
Protect CPU-heavy routes from draining your server.
const heavyLimiter = createLimiter({
algorithm: 'token-bucket',
limit: 100,
windowMs: 60000,
cost: async (req) => {
// Generating a PDF consumes 50 tokens instantly
if (req.path === '/export-pdf') return 50;
// Normal requests consume 1 token
return 1;
}
});📡 Event Telemetry & Monitoring
Connect express-multi-limiter directly to your SIEM, Datadog, or Winston loggers using the exported telemetry EventEmitter.
const { telemetry } = require('express-multi-limiter');
// 🟢 Fired on every successful request passing through
telemetry.on('allowed', ({ key, route, cost, remaining }) => {
// E.g., send metrics to Datadog
});
// 🟡 Fired when a standard rate-limit (429) is hit
telemetry.on('limited', ({ key, route, retryAfterSeconds }) => {
console.log(`User ${key} rate limited at ${route}.`);
});
// 🔴 Fired the exact moment an abuser is sent to the Penalty Box
telemetry.on('jailed', ({ key }) => {
console.warn(`🚨 SECURITY ALERT: IP ${key} was sent to jail!`);
});
// 🔴 Fired when a jailed user or a Blocklisted IP attempts to connect
telemetry.on('blocked', ({ key, reason, route }) => {
console.log(`🛡️ Blocked ${key} (${reason}) at ${route}`);
});
// 💥 Fired if the Redis/Database store throws an error
telemetry.on('error', (error) => {
console.error(`💥 Limiter DB Error:`, error.message);
});🌐 RFC 9333 Standard Headers
By default, the middleware injects modern Internet Engineering Task Force (IETF) standard headers alongside legacy headers:
- RateLimit-Limit: The total request allowance within the window.
- RateLimit-Remaining: The number of requests remaining.
- RateLimit-Reset: The exact number of seconds until the limit resets.
Note: Legacy headers like
X-RateLimit-Limitare also included for backward compatibility with older API clients.
🏰 Architecture: Solving Race Conditions (Mutex)
A common flaw in Node.js rate limiters running purely in memory is concurrency failure. Because standard implementations rely on async/await flows:
- Request A reads count:
5. Event loop yields. - Request B reads count:
5. Event loop yields. - Request A writes count:
6. - Request B writes count:
6. (A request was lost!)
The Mutex Solution
Our MemoryStore implements a strictly ordered Promise-chaining Mutex queue.
const previous = this.locks.get(key) || Promise.resolve();
let release;
const mine = new Promise(resolve => {
release = resolve;
});
const queued = previous.then(() => mine);
this.locks.set(key, queued);Every incoming request for a specific IP is chained to the resolution of the previous request's critical section. This guarantees 100% chronological state safety, preventing abusers from bypassing limits using concurrent blast attacks.
