So you've built an API, and it's working great. Users are happy, everything's smooth. Then one day, someone's poorly written script starts hammering your endpoints thousands of times per minute. Or worse, someone with bad intentions decides to try and take your server down. Suddenly, your API is crawling, real users can't access it, and you're scrambling.
This is exactly why rate limiting exists. Let's talk about what it is, why it matters, and how to build one yourself.
What Is Rate Limiting Anyway?
Think of rate limiting like a bouncer at a club. The bouncer doesn't stop people from coming in entirely , they just make sure not too many people enter at once, and they definitely don't let the same person walk in and out fifty times in a row just to cause chaos.
In API terms, rate limiting controls how many requests a client (usually identified by their IP address) can make to your server within a specific time window. If they exceed that limit, your server responds with a polite "slow down" message , typically an HTTP 429 status code , instead of crashing under the load.
Why Should You Care About Rate Limiting?
There are a few really good reasons to add rate limiting to your API:
Protection against abuse. Without rate limiting, nothing stops someone from sending thousands of requests per second to your server. That could be a malicious attack (DDoS) or just a buggy client script. Either way, your server suffers.
Fair resource usage. Imagine you have a free API, and one user decides to make 10,000 requests per minute while everyone else makes 10. Without limits, that one user eats up all your resources, and everyone else gets slow responses , or no responses at all.
Cost control. If you're paying for server resources or cloud services, runaway requests directly hit your wallet. Rate limiting keeps your costs predictable.
Server stability. Your server has limits. There are only so many database connections, so much memory, so much CPU power. Rate limiting prevents any single client from pushing you past those limits.
Why Redis? Can't I Just Use Memory?
You absolutely can use in-memory storage for rate limiting, and for a simple single-server app, that works fine. But there are problems:
What happens when your server restarts? All your rate limit counters disappear. Everyone gets a fresh start, which defeats the purpose.
What about multiple servers? Most production apps run behind a load balancer with multiple server instances. If Server A tracks rate limits in its memory and Server B tracks them separately, a client could send half their requests to Server A and half to Server B , effectively doubling their allowed limit. Not good.
Redis solves both problems beautifully. It's an in-memory data store, so it's blazing fast (we're talking sub-millisecond operations). But unlike your app's memory, Redis runs as a separate process. All your server instances talk to the same Redis instance, so rate limit tracking stays consistent no matter which server handles the request.
Plus, Redis has features like key expiration and sorted sets that make implementing rate limiting algorithms surprisingly straightforward.
The Project Structure
Before diving into the steps, let's look at how our project is organized. Here's what the folder structure looks like:
redis-rate-limiter/
├── src/
│ ├── config/
│ │ └── index.ts # Configuration management
│ ├── middleware/
│ │ └── rateLimiter.ts # Core rate limiting middleware
│ ├── services/
│ │ └── redisService.ts # Redis operations and strategies
│ ├── types/
│ │ └── index.ts # TypeScript type definitions
│ ├── example/
│ │ └── custom-limiter.ts # Usage examples
│ ├── app.ts # Express application setup
│ └── server.ts # Server entry point
├── .env # Environment variables
├── .env.example # Environment template
├── package.json
├── tsconfig.json
└── README.mdEach folder has a specific job. The config folder handles all configuration. The middleware folder contains the actual rate limiting logic that sits between incoming requests and your route handlers. The services folder manages the Redis connection and the rate limiting algorithms. The types folder holds all our TypeScript type definitions to keep the code safe and predictable.
This separation keeps the code organized. When you want to change how configuration works, you know exactly where to look. When you want to add a new rate limiting algorithm, you head straight to the services folder.
Step 1: Setting Up the Type Definitions
File: src/types/index.ts
// src/types/index.ts
export interface RateLimitConfig {
windowMs: number;
maxRequests: number;
strategy: 'fixed' | 'sliding';
keyGenerator?: (req: any) => string;
skipFailedRequests?: boolean;
skipSuccessfulRequests?: boolean;
}
export interface RateLimitInfo {
limit: number;
current: number;
remaining: number;
resetTime: number;
}
export interface RateLimitResponse {
success: boolean;
limit: number;
remaining: number;
reset: number;
retryAfter?: number;
}Before writing any logic, we need to define what our data looks like. TypeScript shines here because it catches mistakes before they become runtime errors.
We define several interfaces:
RateLimitConfig: This holds the settings for any rate limiter , the maximum number of requests, the time window in milliseconds, and which strategy to use.
RateLimiterOptions: Configuration options when creating a rate limiter instance, including a way to generate custom keys for identifying clients.
RateLimitInfo: Information about the current state of a client's rate limit , how many requests they've made, how many remain, and when the limit resets.
RateLimitResult: The outcome of checking a rate limit , whether the request is allowed, and if not, how long until they can try again.
Defining these upfront means every function in our project speaks the same language. The middleware knows exactly what shape of data it receives from the Redis service, and the Redis service knows exactly what it needs to return.
Step 2: Managing Configuration
File: src/config/index.ts
// src/config/index.ts
import dotenv from 'dotenv';
dotenv.config();
export const config = {
port: parseInt(process.env.PORT || '3000', 10),
redis: {
url: process.env.REDIS_URL || 'redis://localhost:6379',
},
rateLimit: {
windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '60000', 10),
maxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100', 10),
strategy: (process.env.RATE_LIMIT_STRATEGY || 'sliding') as 'fixed' | 'sliding',
},
};Configuration might seem boring, but it's crucial. Hardcoding values like "100 requests per minute" directly in your code is a bad idea because changing them requires editing code, rebuilding, and redeploying.
Instead, we centralize all configuration in one place, pulling values from environment variables with sensible defaults:
PORT: Which port the server runs on (defaults to 3000)
REDIS_URL: Connection string for Redis (defaults to localhost)
RATE_LIMIT_WINDOW_MS: Default time window in milliseconds (defaults to 60000, which is one minute)
RATE_LIMIT_MAX_REQUESTS: Default maximum requests per window (defaults to 100)
RATE_LIMIT_STRATEGY: Which algorithm to use , "fixed" or "sliding" (defaults to "sliding")
The beauty of this approach is flexibility. Want to change the rate limit? Just update an environment variable and restart. No code changes, no rebuild, no fuss.
Step 3: Building the Redis Service
File: src/services/redisService.ts
// src/services/redisService.ts
import Redis from 'ioredis';
import { config } from '../config';
class RedisService {
private client: Redis;
private static instance: RedisService;
private constructor() {
this.client = new Redis(config.redis.url, {
retryStrategy: (times) => {
const delay = Math.min(times * 50, 2000);
return delay;
},
maxRetriesPerRequest: 3,
});
this.client.on('error', (error) => {
console.error('Redis connection error:', error);
});
this.client.on('connect', () => {
console.log('Connected to Redis successfully');
});
}
public static getInstance(): RedisService {
if (!RedisService.instance) {
RedisService.instance = new RedisService();
}
return RedisService.instance;
}
public getClient(): Redis {
return this.client;
}
// Fixed window rate limiting
public async checkFixedWindow(
key: string,
windowMs: number,
maxRequests: number
): Promise<{ allowed: boolean; current: number; remaining: number; reset: number }> {
const now = Date.now();
const windowKey = `${key}:${Math.floor(now / windowMs)}`;
const multi = this.client.multi();
multi.incr(windowKey);
multi.pttl(windowKey);
const results = await multi.exec();
if (!results) {
throw new Error('Redis transaction failed');
}
const current = (results[0][1] as number) || 0;
const ttl = (results[1][1] as number) || windowMs;
if (ttl === -1) {
await this.client.pexpire(windowKey, windowMs);
}
const remaining = Math.max(0, maxRequests - current);
const reset = now + ttl;
return {
allowed: current <= maxRequests,
current,
remaining,
reset,
};
}
// Sliding window rate limiting
public async checkSlidingWindow(
key: string,
windowMs: number,
maxRequests: number
): Promise<{ allowed: boolean; current: number; remaining: number; reset: number }> {
const now = Date.now();
const windowStart = now - windowMs;
const multi = this.client.multi();
// Add current timestamp to sorted set
multi.zadd(key, now, `${now}-${Math.random()}`);
// Remove old entries
multi.zremrangebyscore(key, 0, windowStart);
// Count requests in current window
multi.zcard(key);
// Set expiry on the key
multi.pexpire(key, windowMs);
const results = await multi.exec();
if (!results) {
throw new Error('Redis transaction failed');
}
const current = (results[2][1] as number) || 0;
const remaining = Math.max(0, maxRequests - current);
// Find the oldest timestamp in the window for reset time
const oldestTimestamp = await this.client.zrange(key, 0, 0, 'WITHSCORES');
const reset = oldestTimestamp.length > 0
? parseInt(oldestTimestamp[1]) + windowMs
: now + windowMs;
return {
allowed: current <= maxRequests,
current,
remaining,
reset,
};
}
// Get current rate limit info
public async getRateLimitInfo(
key: string,
strategy: 'fixed' | 'sliding',
windowMs: number,
maxRequests: number
): Promise<{ current: number; remaining: number; reset: number }> {
const now = Date.now();
if (strategy === 'fixed') {
const windowKey = `${key}:${Math.floor(now / windowMs)}`;
const current = parseInt(await this.client.get(windowKey) || '0', 10);
const ttl = await this.client.pttl(windowKey);
return {
current,
remaining: Math.max(0, maxRequests - current),
reset: now + (ttl > 0 ? ttl : windowMs),
};
} else {
const windowStart = now - windowMs;
await this.client.zremrangebyscore(key, 0, windowStart);
const current = await this.client.zcard(key);
const oldestTimestamp = await this.client.zrange(key, 0, 0, 'WITHSCORES');
return {
current,
remaining: Math.max(0, maxRequests - current),
reset: oldestTimestamp.length > 0
? parseInt(oldestTimestamp[1]) + windowMs
: now + windowMs,
};
}
}
// Reset rate limit for a key
public async resetRateLimit(key: string, strategy: 'fixed' | 'sliding'): Promise<void> {
if (strategy === 'fixed') {
const keys = await this.client.keys(`${key}:*`);
if (keys.length > 0) {
await this.client.del(...keys);
}
} else {
await this.client.del(key);
}
}
public async disconnect(): Promise<void> {
await this.client.quit();
}
}
export default RedisService;This is the heart of our rate limiter. The Redis service does two big jobs: managing the Redis connection and implementing the rate limiting algorithms.
Connection Management
We use the Singleton pattern here. Why? Because we only want one Redis connection shared across the entire application. Creating a new connection for every request would be wasteful and slow. The singleton ensures the connection is created once (lazily, on first use) and reused thereafter.
The service also handles connection failures gracefully. If Redis is down, the service logs the error, sets a flag, and moves on. This enables our "fail-open" behavior , when Redis is unavailable, we let requests through rather than blocking everyone.
Rate Limiting Algorithms
We implement two strategies, and both get the same question: "Should I allow this request?"
Fixed Window Strategy
Imagine dividing time into neat little boxes , say, one-minute windows. When a request comes in, we check how many requests this client has made in the current box. If they're under the limit, we increment the counter and allow the request. If they've hit the limit, we reject it.
In Redis, this is implemented using simple key-value pairs with expiration. The key combines the client identifier (usually IP address) with the current window timestamp. We increment the key using Redis's atomic INCR command and set an expiration equal to the window duration. At the end of the window, Redis automatically deletes the key, and the counter resets.
This approach is simple and memory-efficient, but it has a weakness at window boundaries. If the limit is 100 requests per minute, a client could make 100 requests at 11:59:59 and another 100 at 12:00:01 , effectively 200 requests in two seconds while technically staying within the limit for each window.
Sliding Window Strategy
The sliding window fixes this edge case. Instead of fixed time boxes, we look back over a continuously sliding time period. When a request arrives, we ask: "How many requests has this client made in the last 60 seconds?" If under the limit, we allow it.
Redis sorted sets are perfect for this. Each request is added to a sorted set with its timestamp as the score. When checking, we first remove any entries older than our window (using Redis's ZREMRANGEBYSCORE command), then count how many remain. If the count is under the limit, we add the new request timestamp and allow it.
This is more accurate than the fixed window, but uses more Redis memory because we're storing individual timestamps rather than just a counter.
Step 4: Creating the Rate Limiter Middleware
File: src/middleware/rateLimiter.ts
// src/middleware/rateLimiter.ts
import { Request, Response, NextFunction } from 'express';
import { v4 as uuidv4 } from 'uuid';
import RedisService from '../services/redisService';
import { RateLimitConfig } from '../types';
export class RateLimiter {
private redisService: RedisService;
private defaultConfig: RateLimitConfig;
constructor(config?: Partial<RateLimitConfig>) {
this.redisService = RedisService.getInstance();
this.defaultConfig = {
windowMs: 60000, // 1 minute
maxRequests: 100,
strategy: 'sliding',
keyGenerator: (req: Request) => {
// Use IP and route combination as key
return `ratelimit:${req.ip}:${req.path}`;
},
...config,
};
}
public middleware(config?: Partial<RateLimitConfig>) {
const finalConfig = { ...this.defaultConfig, ...config };
return async (req: Request, res: Response, next: NextFunction) => {
try {
// Generate request ID for tracking
const requestId = uuidv4();
req.headers['x-request-id'] = requestId;
// Generate key for rate limiting
const key = finalConfig.keyGenerator!(req);
// Check rate limit based on strategy
const result = finalConfig.strategy === 'fixed'
? await this.redisService.checkFixedWindow(key, finalConfig.windowMs, finalConfig.maxRequests)
: await this.redisService.checkSlidingWindow(key, finalConfig.windowMs, finalConfig.maxRequests);
// Set rate limit headers
res.setHeader('X-RateLimit-Limit', finalConfig.maxRequests);
res.setHeader('X-RateLimit-Remaining', result.remaining);
res.setHeader('X-RateLimit-Reset', Math.ceil(result.reset / 1000));
res.setHeader('X-Request-ID', requestId);
if (!result.allowed) {
const retryAfter = Math.ceil((result.reset - Date.now()) / 1000);
res.setHeader('Retry-After', retryAfter);
res.setHeader('X-RateLimit-Retry-After', retryAfter);
return res.status(429).json({
success: false,
error: {
code: 'RATE_LIMIT_EXCEEDED',
message: 'Too many requests, please try again later.',
retryAfter,
limit: finalConfig.maxRequests,
remaining: 0,
reset: Math.ceil(result.reset / 1000),
},
requestId,
});
}
// Store rate limit info for downstream use
(req as any).rateLimit = {
limit: finalConfig.maxRequests,
current: result.current,
remaining: result.remaining,
resetTime: result.reset,
};
next();
} catch (error) {
console.error('Rate limiter error:', error);
// Fail open - allow request if Redis is down
res.setHeader('X-RateLimit-Status', 'Error: Rate limiting unavailable');
next();
}
};
}
// Utility method to create route-specific limiters
public createRouteLimiter(maxRequests: number, windowMs?: number, strategy?: 'fixed' | 'sliding') {
return this.middleware({
maxRequests,
windowMs: windowMs || this.defaultConfig.windowMs,
strategy: strategy || this.defaultConfig.strategy,
});
}
// Get current rate limit info for debugging
public async getRateLimitStatus(req: Request) {
const key = this.defaultConfig.keyGenerator!(req);
return await this.redisService.getRateLimitInfo(
key,
this.defaultConfig.strategy,
this.defaultConfig.windowMs,
this.defaultConfig.maxRequests
);
}
// Reset rate limit for a specific key
public async resetLimit(req: Request) {
const key = this.defaultConfig.keyGenerator!(req);
await this.redisService.resetRateLimit(key, this.defaultConfig.strategy);
}
}
// Pre-configured limiters for different use cases
export const rateLimiter = new RateLimiter();
export const standardLimiter = rateLimiter.middleware();
export const strictLimiter = rateLimiter.createRouteLimiter(10, 60000, 'sliding'); // 10 requests per minute
export const authLimiter = rateLimiter.createRouteLimiter(5, 900000, 'fixed'); // 5 requests per 15 minutes
export const apiLimiter = rateLimiter.createRouteLimiter(1000, 3600000, 'sliding'); // 1000 requests per hourNow we bridge the gap between our Redis service and Express. The middleware sits in the request pipeline and intercepts every request before it reaches your actual route handlers.
The RateLimiter class provides several features:
Preset configurations. Instead of making developers specify every parameter, we provide ready-to-use presets:
Standard: 100 requests per minute , good for general API endpoints
Strict: 10 requests per minute , for sensitive or resource-heavy endpoints
Auth: 5 requests per minute , for login or registration endpoints that should be heavily restricted
API: 1000 requests per minute , for high-throughput internal or trusted endpoints
The middleware function. When a request arrives, the middleware:
Generates a unique request ID using UUID , this helps with debugging and tracing
Creates a key to identify the client (default: IP address + route path)
Calls the Redis service to check if the request should be allowed
If allowed, passes the request to the next handler in the chain
If denied, returns a 429 status with helpful headers like
Retry-AfterandX-RateLimit-Reset
Rate limit headers. Every response includes standard headers so clients can monitor their own usage:
X-RateLimit-Limit: The maximum requests allowedX-RateLimit-Remaining: How many requests they have leftX-RateLimit-Reset: When the window resets (Unix timestamp)Retry-After: How many seconds to wait before retrying (only on 429 responses)
Custom key generation. The default client identification uses IP address plus route, but you can provide a custom key generator. Want to rate limit based on user ID from an authentication token? Just pass a function that extracts the user ID from the request.
Admin endpoints. For debugging and management, we include endpoints to check current rate limit status and reset limits for specific clients.
Step 5: Setting Up the Express Application
File: src/app.ts
// src/app.ts
import express, { Request, Response, NextFunction } from 'express';
import { rateLimiter, standardLimiter, strictLimiter, authLimiter, apiLimiter } from './middleware/rateLimiter';
import { config } from './config';
const app = express();
// Middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Trust proxy for accurate IP detection
app.set('trust proxy', 1);
// Apply standard rate limiting to all routes
app.use(standardLimiter);
// Routes
app.get('/', (req: Request, res: Response) => {
res.json({
success: true,
message: 'Rate Limiter API is running',
rateLimit: (req as any).rateLimit,
});
});
// Public API with higher limits
app.get('/api/public', apiLimiter, (req: Request, res: Response) => {
res.json({
success: true,
message: 'Public API endpoint',
data: {
timestamp: new Date().toISOString(),
rateLimit: (req as any).rateLimit,
},
});
});
// Authentication endpoints with strict limits
app.post('/api/auth/login', authLimiter, (req: Request, res: Response) => {
res.json({
success: true,
message: 'Login endpoint (rate limited)',
rateLimit: (req as any).rateLimit,
});
});
// Sensitive operations with very strict limits
app.post('/api/sensitive', strictLimiter, (req: Request, res: Response) => {
res.json({
success: true,
message: 'Sensitive operation endpoint (strict rate limit)',
rateLimit: (req as any).rateLimit,
});
});
// Rate limit status endpoint (admin/debugging)
app.get('/api/rate-limit-status', async (req: Request, res: Response) => {
try {
const status = await rateLimiter.getRateLimitStatus(req);
res.json({
success: true,
data: status,
});
} catch (error) {
res.status(500).json({
success: false,
error: 'Failed to get rate limit status',
});
}
});
// Reset rate limit (admin endpoint - should be protected)
app.post('/api/admin/reset-rate-limit', async (req: Request, res: Response) => {
try {
const targetIp = req.body.ip || req.ip;
const mockReq = { ...req, ip: targetIp } as Request;
await rateLimiter.resetLimit(mockReq);
res.json({
success: true,
message: `Rate limit reset for ${targetIp}`,
});
} catch (error) {
res.status(500).json({
success: false,
error: 'Failed to reset rate limit',
});
}
});
// Error handling middleware
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
console.error('Unhandled error:', err);
res.status(500).json({
success: false,
error: {
code: 'INTERNAL_SERVER_ERROR',
message: 'An unexpected error occurred',
},
});
});
// 404 handler
app.use((req: Request, res: Response) => {
res.status(404).json({
success: false,
error: {
code: 'NOT_FOUND',
message: `Route ${req.method} ${req.path} not found`,
},
});
});
export default app;This is where we wire everything together. The Express app configuration:
Sets up the rate limiter middleware with different presets for different routes
Adds a simple test endpoint that returns a success message (so you can see rate limiting in action)
Includes the rate limit status checking endpoint
Includes the admin reset endpoint
The beauty of the middleware pattern is that you can apply rate limiting at different levels:
Globally: Apply to every route by using
app.use(limiter.middleware())Per route: Apply different limits to different endpoints
Per router: Group related endpoints under the same limit
Step 6: The Server Entry Point
File: src/server.ts
// src/server.ts
import app from './app';
import { config } from './config';
import RedisService from './services/redisService';
const startServer = async () => {
try {
// Initialize Redis connection
const redisService = RedisService.getInstance();
// Verify Redis connection
await redisService.getClient().ping();
console.log('Redis connection verified');
// Start server
app.listen(config.port, () => {
console.log(`Server running on port ${config.port}`);
console.log(`Rate limiting strategy: ${config.rateLimit.strategy}`);
console.log(`Rate limit: ${config.rateLimit.maxRequests} requests per ${config.rateLimit.windowMs}ms`);
});
} catch (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
};
// Handle graceful shutdown
process.on('SIGTERM', async () => {
console.log('SIGTERM received. Shutting down gracefully...');
const redisService = RedisService.getInstance();
await redisService.disconnect();
process.exit(0);
});
process.on('SIGINT', async () => {
console.log('SIGINT received. Shutting down gracefully...');
const redisService = RedisService.getInstance();
await redisService.disconnect();
process.exit(0);
});
startServer();The server file is simple but handles an important concern: graceful startup and shutdown.
When the server starts, it verifies that Redis is reachable. If Redis isn't available, it logs a warning but starts anyway , remember our fail-open design. The application will work, just without rate limiting until Redis comes back.
When the server receives a shutdown signal (like when you press Ctrl+C), it:
Stops accepting new requests
Waits for in-flight requests to complete
Gracefully closes the Redis connection
Exits the process
This prevents connection leaks and ensures a clean shutdown.
Step 7: Node.js Project Settings
File: package.json
{
"name": "rate-limiter-api",
"version": "1.0.0",
"description": "API Rate Limiting with Node.js, TypeScript and Redis",
"main": "dist/server.js",
"scripts": {
"build": "tsc",
"start": "node dist/server.js",
"dev": "ts-node-dev --respawn --transpile-only src/server.ts",
"update": "npx npm-check-updates -u"
},
"dependencies": {
"dotenv": "^17.4.2",
"express": "^5.2.1",
"ioredis": "^6.0.0",
"uuid": "^14.0.1"
},
"devDependencies": {
"@types/express": "^5.0.6",
"@types/node": "^26.1.2",
"@types/uuid": "^11.0.0",
"ts-node-dev": "^2.0.0",
"typescript": "^7.0.2"
}
}Step 8: TypeScript Settings
File: tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": [
"ES2020"
],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist"
]
}The Two Algorithms: A Simple Comparison
Let's make the difference between fixed and sliding window crystal clear with a real-world analogy.
Fixed Window is like a parking garage that resets its counter every hour on the hour. At 2:59 PM, 100 cars enter. At 3:00 PM, the counter resets to zero, and another 100 cars can enter immediately. In two minutes, 200 cars entered , but technically, no hourly limit was exceeded.
Sliding Window is like a parking garage that always asks: "How many cars entered in the last 60 minutes?" If 100 cars entered at 2:59 PM, then at 3:00 PM, the answer to "how many in the last 60 minutes?" is still 100 (those cars haven't left the window yet). No new cars can enter until some of those timestamps fall outside the 60-minute window.
Sliding window is more accurate but uses more memory. Fixed window is simpler but has that boundary issue. Which one you choose depends on your needs , for most APIs, the sliding window is worth the extra memory cost.
Fail-Open: Why Let Requests Through When Redis Is Down?
This is a deliberate design choice that deserves explanation. When Redis is unavailable, we have two options:
Fail-closed (reject all requests): Nobody gets through. Your API is effectively down for everyone. This "protects" your backend but also means zero users can access your service , probably worse than the problem you were trying to prevent.
Fail-open (allow all requests): Requests pass through without rate limiting. Your backend might get overwhelmed if it was already under heavy load, but at least legitimate users can still access your API.
We chose fail-open because availability usually matters more than perfect rate limiting. If Redis goes down, that's a separate incident to fix. But taking down your entire API because the rate limiter's storage is unavailable? That's letting the cure be worse than the disease.
That said, in a production system, you'd want alerts firing the moment Redis becomes unreachable so you can fix it before any abuse actually happens.
How to Actually Use This Rate Limiter
Once everything is set up, using the rate limiter in your Express app is straightforward:
Import the RateLimiter class
Create an instance (optionally with custom options)
Apply it as middleware to your routes
You can use presets for common scenarios or create fully customized limiters for specific routes. The admin endpoints let you check status and reset limits without restarting the server.
Wrapping Up
Building a rate limiter teaches you a lot about middleware architecture, distributed systems, and the tradeoffs between simplicity and accuracy. What we built here is production-ready for many use cases , it handles distributed scenarios via Redis, fails gracefully, and gives clients the information they need to respect your limits.
There's always more you could add: token bucket algorithms for burst handling, IP whitelisting, a monitoring dashboard, Redis cluster support. But for an MVP, this covers the essential ground and provides a solid foundation to build on.
The most important takeaway? Rate limiting isn't just about stopping bad actors. It's about being a good citizen of the internet , protecting your resources, treating all your users fairly, and keeping your service stable and responsive for everyone.

