I was building an API that suddenly started getting hammered by traffic last week. The service became unresponsive, and I realized I’d made a classic mistake: no rate limiting. That experience sparked my journey into creating production-ready rate limiting systems. Today, I want to share what I’ve learned about building scalable, distributed rate limiting using Redis and Node.js.
Have you ever wondered what happens when your API suddenly gets thousands of requests per second?
Rate limiting isn’t just about preventing abuse. It’s about creating fair usage policies, protecting your infrastructure, and ensuring consistent performance for all users. When done right, it becomes invisible to legitimate users while effectively blocking malicious traffic.
Let me show you how I built a system that handles millions of requests across multiple servers.
Setting Up Our Foundation
We’ll use Redis because it provides atomic operations and exceptional speed. Here’s our basic setup:
const Redis = require('ioredis');
const express = require('express');
const redis = new Redis(process.env.REDIS_URL);
const app = express();
// Simple middleware structure
app.use((req, res, next) => {
// Rate limiting logic goes here
next();
});
But this is just the beginning. The real power comes from choosing the right algorithm for your needs.
Understanding the Core Algorithms
I experimented with three main approaches. The fixed window algorithm is simplest but can allow bursts at window boundaries. The sliding window provides smoother control but requires more computation. The token bucket offers burst capability with steady refills.
Which algorithm would work best for your API endpoints?
Here’s a practical implementation of the sliding window algorithm:
class SlidingWindowRateLimiter {
constructor(redis, windowMs, maxRequests) {
this.redis = redis;
this.windowMs = windowMs;
this.maxRequests = maxRequests;
}
async checkLimit(userId) {
const key = `rate_limit:${userId}`;
const now = Date.now();
const windowStart = now - this.windowMs;
// Remove old requests
await this.redis.zremrangebyscore(key, 0, windowStart);
// Count current requests
const currentCount = await this.redis.zcard(key);
if (currentCount >= this.maxRequests) {
return { allowed: false, retryAfter: this.getRetryAfter(key) };
}
// Add current request
await this.redis.zadd(key, now, now);
await this.redis.expire(key, Math.ceil(this.windowMs / 1000));
return { allowed: true, remaining: this.maxRequests - currentCount - 1 };
}
}
This approach gives us precise control over the time window while maintaining Redis’ performance benefits.
Building Production-Grade Middleware
The real value comes from creating reusable middleware. Here’s how I structured mine:
function createRateLimitMiddleware(options = {}) {
const {
windowMs = 15 * 60 * 1000, // 15 minutes
maxRequests = 100,
keyGenerator = (req) => req.ip,
skip = () => false,
onLimitReached = (req, res) => {
res.status(429).json({ error: 'Too many requests' });
}
} = options;
const limiter = new SlidingWindowRateLimiter(redis, windowMs, maxRequests);
return async (req, res, next) => {
if (skip(req)) return next();
try {
const key = keyGenerator(req);
const result = await limiter.checkLimit(key);
if (!result.allowed) {
return onLimitReached(req, res);
}
// Add rate limit headers
res.set({
'X-RateLimit-Limit': maxRequests,
'X-RateLimit-Remaining': result.remaining,
'X-RateLimit-Reset': new Date(Date.now() + result.retryAfter).toISOString()
});
next();
} catch (error) {
// Fail open - don't block requests if Redis fails
console.error('Rate limit error:', error);
next();
}
};
}
What happens when Redis becomes unavailable? We need to handle failures gracefully.
Handling Different User Tiers
In production, you’ll likely need different limits for different users. Here’s how I implemented tiered rate limiting:
function createTieredRateLimit(tiers) {
return async (req, res, next) => {
const userTier = req.user?.tier || 'free';
const tierConfig = tiers[userTier] || tiers.free;
const middleware = createRateLimitMiddleware(tierConfig);
return middleware(req, res, next);
};
}
// Usage
app.use(createTieredRateLimit({
free: { windowMs: 900000, maxRequests: 100 },
premium: { windowMs: 900000, maxRequests: 1000 },
enterprise: { windowMs: 900000, maxRequests: 10000 }
}));
This approach allows you to monetize your API while keeping it accessible to free users.
Monitoring and Analytics
You can’t improve what you don’t measure. I added monitoring to track rate limit events:
class RateLimitMonitor {
constructor(redis) {
this.redis = redis;
}
async trackHit(identifier, tier, allowed) {
const timestamp = Date.now();
const event = {
identifier,
tier,
allowed,
timestamp
};
// Store recent events for real-time monitoring
await this.redis.lpush('rate_limit_events', JSON.stringify(event));
await this.redis.ltrim('rate_limit_events', 0, 999); // Keep last 1000 events
// Increment counters for analytics
const key = `rate_limit_stats:${identifier}:${new Date().toISOString().split('T')[0]}`;
await this.redis.hincrby(key, allowed ? 'allowed' : 'blocked', 1);
await this.redis.expire(key, 86400); // Keep for 24 hours
}
}
This data helps you understand usage patterns and adjust your limits accordingly.
Testing Your Implementation
Comprehensive testing is crucial. Here’s how I test the rate limiting logic:
describe('Rate Limiting', () => {
it('should allow requests within limit', async () => {
const limiter = new SlidingWindowRateLimiter(redis, 60000, 5);
for (let i = 0; i < 5; i++) {
const result = await limiter.checkLimit('test-user');
expect(result.allowed).toBe(true);
}
});
it('should block requests over limit', async () => {
const limiter = new SlidingWindowRateLimiter(redis, 60000, 5);
for (let i = 0; i < 6; i++) {
const result = await limiter.checkLimit('test-user');
if (i >= 5) {
expect(result.allowed).toBe(false);
}
}
});
});
Performance Considerations
In high-traffic environments, every millisecond counts. I optimized performance by:
- Using Redis pipelines for batch operations
- Implementing local caches for frequent users
- Using Lua scripts for complex atomic operations
- Monitoring Redis memory usage and connection counts
Did you know that proper rate limiting can actually improve your API’s overall performance?
Deployment Strategy
When deploying to production, consider these factors:
- Use Redis clusters for high availability
- Implement circuit breakers for Redis failures
- Set appropriate timeouts and retry policies
- Monitor key expiration and memory usage
- Have a rollback plan for configuration changes
The system I built now handles millions of requests daily across multiple geographic regions. It’s saved us from several potential outages and helped us maintain consistent service quality.
What challenges have you faced with rate limiting in your projects?
Building production-ready rate limiting requires careful planning and testing, but the investment pays off in reliability and user satisfaction. Start with a simple implementation and gradually add complexity as your needs evolve.
If you found this guide helpful or have questions about implementing rate limiting in your own projects, I’d love to hear from you. Share your experiences in the comments below, and don’t forget to share this article with your team if you think it could help them build more resilient systems.