Build a Rate Limiter for an API
Introduction
Rate limiting is essential for API security. It protects your services from abuse, prevents server overload, and ensures fair usage among clients.
In this tutorial, we'll build a rate limiter from scratch using various algorithms and implement it in an Express.js API.
- A custom rate limiter middleware
- Token bucket algorithm implementation
- Sliding window rate limiter
- Redis-based distributed rate limiting
- Multiple rate limit strategies
- Rate limiting algorithms
- Express middleware development
- Redis for distributed systems
- API security best practices
- IP-based and user-based limiting
Project Overview
Our rate limiter will support multiple strategies and configurations:
Features
- Token Bucket - Smooth rate limiting
- Sliding Window - Precise limiting
- Fixed Window - Simple and fast
- Redis Backend - Distributed limiting
- Custom Rules - Per-IP, per-user limits
Prerequisites
- Node.js installed - Version 14+
- Express.js - Basic knowledge
- Redis - For distributed limiting (optional)
- JavaScript - ES6+ syntax
Project Setup
# Create project directory
mkdir rate-limiter
cd rate-limiter
# Initialize Node.js
npm init -y
# Install dependencies
npm install express ioredis express-rate-limit
# Install for demo
npm install cors
Project Structure
rate-limiter/
├── middleware/
│ ├── rateLimiter.js
│ ├── tokenBucket.js
│ └── slidingWindow.js
├── stores/
│ ├── memoryStore.js
│ └── redisStore.js
├── server.js
└── package.json
Token Bucket Algorithm
The token bucket algorithm allows bursts while maintaining an average rate. Tokens are added to a bucket at a fixed rate, and each request consumes tokens.
// middleware/tokenBucket.js
class TokenBucket {
constructor(capacity, refillRate) {
this.capacity = capacity;
this.refillRate = refillRate;
this.buckets = new Map();
// Start automatic refill
setInterval(() => this.refillAll(), 1000);
}
consume(key) {
let bucket = this.buckets.get(key);
if (!bucket) {
bucket = {
tokens: this.capacity,
lastRefill: Date.now()
};
this.buckets.set(key, bucket);
}
this.refill(bucket);
if (bucket.tokens >= 1) {
bucket.tokens -= 1;
return {
allowed: true,
remainingTokens: Math.floor(bucket.tokens)
};
}
return {
allowed: false,
remainingTokens: 0,
retryAfter: Math.ceil((1 - bucket.tokens) / this.refillRate)
};
}
refill(bucket) {
const now = Date.now();
const timePassed = (now - bucket.lastRefill) / 1000;
const tokensToAdd = timePassed * this.refillRate;
bucket.tokens = Math.min(this.capacity, bucket.tokens + tokensToAdd);
bucket.lastRefill = now;
}
refillAll() {
for (const bucket of this.buckets.values()) {
this.refill(bucket);
}
}
reset(key) {
this.buckets.delete(key);
}
getStatus(key) {
let bucket = this.buckets.get(key);
if (!bucket) {
return {
tokens: this.capacity,
capacity: this.capacity,
refillRate: this.refillRate
};
}
this.refill(bucket);
return {
tokens: Math.floor(bucket.tokens),
capacity: this.capacity,
refillRate: this.refillRate
};
}
}
module.exports = TokenBucket;
- Capacity - Maximum tokens in the bucket
- Refill Rate - Tokens added per second
- Consume - Request consumes 1 token
- Burst - Up to capacity requests at once
Basic Rate Limiter
Let's create a basic in-memory rate limiter middleware.
// middleware/rateLimiter.js
const TokenBucket = require('./tokenBucket');
class RateLimiter {
constructor(options = {}) {
this.windowSize = options.windowSize || 60000;
this.maxRequests = options.maxRequests || 100;
this.keyGenerator = options.keyGenerator || this.defaultKeyGenerator;
this.handler = options.handler || this.defaultHandler;
// Store for tracking requests
this.store = new Map();
// Cleanup old entries periodically
setInterval(() => this.cleanup(), this.windowSize);
}
defaultKeyGenerator(req) {
return req.ip || req.connection.remoteAddress;
}
defaultHandler(req, res) {
res.status(429).json({
error: 'Too Many Requests',
message: 'Rate limit exceeded. Please try again later.'
});
}
isAllowed(key) {
const now = Date.now();
const windowStart = now - this.windowSize;
if (!this.store.has(key)) {
this.store.set(key, []);
}
const requests = this.store.get(key);
// Remove old requests outside the window
const validRequests = requests.filter(time => time > windowStart);
this.store.set(key, validRequests);
if (validRequests.length >= this.maxRequests) {
const oldestRequest = validRequests[0];
const retryAfter = Math.ceil((oldestRequest + this.windowSize - now) / 1000);
return {
allowed: false,
remaining: 0,
resetTime: oldestRequest + this.windowSize,
retryAfter
};
}
validRequests.push(now);
return {
allowed: true,
remaining: this.maxRequests - validRequests.length,
resetTime: now + this.windowSize
};
}
middleware() {
return (req, res, next) => {
const key = this.keyGenerator(req);
const result = this.isAllowed(key);
// Set rate limit headers
res.set('X-RateLimit-Limit', this.maxRequests);
res.set('X-RateLimit-Remaining', result.remaining);
res.set('X-RateLimit-Reset', Math.ceil(result.resetTime / 1000));
if (!result.allowed) {
res.set('Retry-After', result.retryAfter);
return this.handler(req, res);
}
next();
};
}
cleanup() {
const now = Date.now();
const windowStart = now - this.windowSize;
for (const [key, requests] of this.store.entries()) {
const validRequests = requests.filter(time => time > windowStart);
if (validRequests.length === 0) {
this.store.delete(key);
} else {
this.store.set(key, validRequests);
}
}
}
}
module.exports = RateLimiter;
Distributed Rate Limiting
For multi-server deployments, we need Redis-based rate limiting.
// stores/redisStore.js
const Redis = require('ioredis');
class RedisStore {
constructor(options = {}) {
this.redis = new Redis(options.redis || {
host: options.host || 'localhost',
port: options.port || 6379
});
this.prefix = options.prefix || 'ratelimit:';
}
_getKey(key) {
return this.prefix + key;
}
increment(key, windowSize) {
const redisKey = this._getKey(key);
return this.redis
.multi()
.incr(redisKey)
.pttl(redisKey)
.exec()
.then(([[, count], [, ttl]]) => {
// Set expiry on first request
if (ttl === -1) {
return this.redis.pexpire(redisKey, windowSize)
.then(() => ({ count: 1, ttl: windowSize }));
}
return { count, ttl };
});
}
decrement(key) {
const redisKey = this._getKey(key);
return this.redis.decr(redisKey);
}
reset(key) {
const redisKey = this._getKey(key);
return this.redis.del(redisKey);
}
getStatus(key, maxRequests, windowSize) {
const redisKey = this._getKey(key);
return this.redis
.pipeline()
.get(redisKey)
.pttl(redisKey)
.exec()
.then(([[, count], [, ttl]]) => {
const currentCount = parseInt(count) || 0;
const remaining = Math.max(0, maxRequests - currentCount);
const resetTime = ttl > 0 ? Date.now() + ttl : Date.now() + windowSize;
return {
allowed: currentCount < maxRequests,
remaining,
resetTime,
ttl
};
});
}
close() {
return this.redis.quit();
}
}
// Distributed rate limiter using Redis
class DistributedRateLimiter {
constructor(options = {}) {
this.store = options.store || new RedisStore(options);
this.maxRequests = options.maxRequests || 100;
this.windowSize = options.windowSize || 60000;
this.keyGenerator = options.keyGenerator || this.defaultKeyGenerator;
}
defaultKeyGenerator(req) {
return req.ip || req.connection.remoteAddress;
}
middleware() {
return async (req, res, next) => {
const key = this.keyGenerator(req);
try {
const result = await this.store.increment(key, this.windowSize);
res.set('X-RateLimit-Limit', this.maxRequests);
res.set('X-RateLimit-Remaining',
Math.max(0, this.maxRequests - result.count));
res.set('X-RateLimit-Reset',
Math.ceil((Date.now() + result.ttl) / 1000));
if (result.count > this.maxRequests) {
return res.status(429).json({
error: 'Too Many Requests',
retryAfter: Math.ceil(result.ttl / 1000)
});
}
next();
} catch (error) {
console.error('Rate limiter error:', error);
next();
}
};
}
}
module.exports = { RedisStore, DistributedRateLimiter };
Express Integration
Let's integrate the rate limiter with Express and create different limiting strategies.
// server.js
const express = require('express');
const cors = require('cors');
const RateLimiter = require('./middleware/rateLimiter');
const { DistributedRateLimiter } = require('./stores/redisStore');
const app = express();
app.use(cors());
app.use(express.json());
// Create different rate limiters for different use cases
// 1. Global rate limiter - 100 requests per minute
const globalLimiter = new RateLimiter({
windowSize: 60 * 1000,
maxRequests: 100
});
// 2. Stricter limiter for auth endpoints - 5 requests per minute
const authLimiter = new RateLimiter({
windowSize: 60 * 1000,
maxRequests: 5,
keyGenerator: (req) => `auth:${req.ip}`
});
// 3. Generous limiter for premium users - 1000 requests per minute
const premiumLimiter = new RateLimiter({
windowSize: 60 * 1000,
maxRequests: 1000
});
// 4. Distributed rate limiter (requires Redis)
// const distributedLimiter = new DistributedRateLimiter({
// maxRequests: 100,
// windowSize: 60 * 1000
// });
// Apply global rate limiter to all routes
app.use(globalLimiter.middleware());
// API routes
app.get('/api/public', (req, res) => {
res.json({ message: 'Public endpoint - global rate limit applies' });
});
// Auth routes with stricter limiting
app.post('/api/auth/login', authLimiter.middleware(), (req, res) => {
res.json({ message: 'Login attempt - stricter rate limit' });
});
app.post('/api/auth/register', authLimiter.middleware(), (req, res) => {
res.json({ message: 'Register attempt - stricter rate limit' });
});
// Premium endpoint with generous limiting
app.get('/api/premium/data', premiumLimiter.middleware(), (req, res) => {
res.json({ message: 'Premium endpoint - generous rate limit' });
});
// Example: Skip rate limiting for whitelisted IPs
const whitelist = ['127.0.0.1', '::1'];
app.get('/api/whitelist-test', (req, res, next) => {
if (whitelist.includes(req.ip)) {
return next();
}
return globalLimiter.middleware()(req, res, next);
}, (req, res) => {
res.json({ message: 'Whitelisted or rate limited' });
});
// Health check
app.get('/health', (req, res) => {
res.json({ status: 'ok' });
});
// Status endpoint to see rate limit info
app.get('/api/status', (req, res) => {
const key = globalLimiter.keyGenerator(req);
const status = globalLimiter.isAllowed(key);
res.json({
key,
...status,
limit: globalLimiter.maxRequests
});
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
- X-RateLimit-Limit - Maximum requests allowed
- X-RateLimit-Remaining - Remaining requests
- X-RateLimit-Reset - Unix timestamp when limit resets
- Retry-After - Seconds to wait before retrying
Custom Strategies
Let's create custom rate limiting strategies for different scenarios.
// Advanced rate limiting strategies
// 1. Leaky Bucket Algorithm
class LeakyBucket {
constructor(capacity, leakRate) {
this.capacity = capacity;
this.leakRate = leakRate;
this.level = 0;
this.lastLeak = Date.now();
setInterval(() => this.leak(), 1000);
}
leak() {
const now = Date.now();
const timePassed = (now - this.lastLeak) / 1000;
const amountLeaked = timePassed * this.leakRate;
this.level = Math.max(0, this.level - amountLeaked);
this.lastLeak = now;
}
add(amount = 1) {
this.leak();
if (this.level + amount <= this.capacity) {
this.level += amount;
return { allowed: true, level: this.level };
}
return {
allowed: false,
level: this.level,
retryAfter: Math.ceil((this.level + amount - this.capacity) / this.leakRate)
};
}
}
// 2. Sliding Window Rate Limiter
class SlidingWindowRateLimiter {
constructor(maxRequests, windowSize) {
this.maxRequests = maxRequests;
this.windowSize = windowSize;
this.requests = new Map();
}
isAllowed(key) {
const now = Date.now();
const windowStart = now - this.windowSize;
if (!this.requests.has(key)) {
this.requests.set(key, []);
}
const timestamps = this.requests.get(key);
// Remove old timestamps
const validTimestamps = timestamps.filter(ts => ts > windowStart);
if (validTimestamps.length >= this.maxRequests) {
const oldestValid = validTimestamps[0];
return {
allowed: false,
remaining: 0,
retryAfter: Math.ceil((oldestValid + this.windowSize - now) / 1000)
};
}
validTimestamps.push(now);
this.requests.set(key, validTimestamps);
return {
allowed: true,
remaining: this.maxRequests - validTimestamps.length
};
}
}
// 3. Adaptive Rate Limiter
class AdaptiveRateLimiter {
constructor(baseLimit = 100) {
this.baseLimit = baseLimit;
this.errorCounts = new Map();
this.successCounts = new Map();
}
getLimit(key) {
const errors = this.errorCounts.get(key) || 0;
const successes = this.successCounts.get(key) || 0;
// Decrease limit if many errors
if (errors > 10) {
return Math.max(10, this.baseLimit * 0.1);
}
// Increase limit if mostly successful
if (successes > 100 && errors < 5) {
return Math.min(1000, this.baseLimit * 1.5);
}
return this.baseLimit;
}
recordSuccess(key) {
const current = this.successCounts.get(key) || 0;
this.successCounts.set(key, current + 1);
}
recordError(key) {
const current = this.errorCounts.get(key) || 0;
this.errorCounts.set(key, current + 1);
}
}
Testing
# Start the server
node server.js
# Test rate limiting
# Make multiple requests (should hit limit after 100)
for i in {1..105}; do
curl -s -o /dev/null -w "Request $i: %{http_code}\n" http://localhost:3000/api/public
done
# Check rate limit headers
curl -I http://localhost:3000/api/public
# Test status endpoint
curl http://localhost:3000/api/status
# Test stricter auth limits (5 requests)
for i in {1..7}; do
curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{}' \
-w "Request $i: %{http_code}\n"
done
- ✓ Rate limiting applies after threshold
- ✓ Correct headers are set
- ✓ Different limits for different routes
- ✓ Custom key generators work
- ✓ Redis distributed limiting works
Summary
Congratulations! You've built a comprehensive rate limiting system.
What You Built
- Token Bucket - Burst-friendly limiting
- Leaky Bucket - Smooth outflow limiting
- Sliding Window - Precise time-based limiting
- Redis Store - Distributed rate limiting
- Adaptive Limiter - Dynamic limits based on behavior
Next Steps
- Add caching layer
- Implement request queuing
- Add dashboard for monitoring
- Integrate with API gateway