Rate limiting protects your API from abuse, ensures fair resource allocation, and maintains service availability. Whether you're preventing DDoS attacks or managing API quotas, proper rate limiting is essential for production APIs. This guide covers implementing effective rate limiting from a senior developer's perspective.
Why Rate Limiting Matters
Rate limiting protects against:
- DDoS Attacks: Prevent service overwhelm
- Brute Force: Block credential stuffing attempts
- Resource Abuse: Fair usage among clients
- Cost Control: Limit expensive operations
- API Monetization: Enforce plan limits
Rate Limiting Strategies
Fixed Window
Count requests in fixed time periods (e.g., 100 requests per minute).
Pros: Simple to implement Cons: Bursts at window boundaries
// Node.js with Redis
const Redis = require('ioredis');
const redis = new Redis();
async function fixedWindowLimit(userId, limit, windowSeconds) {
const key = `ratelimit:${userId}:${Math.floor(Date.now() / 1000 / windowSeconds)}`;
const current = await redis.incr(key);
if (current === 1) {
await redis.expire(key, windowSeconds);
}
return {
allowed: current <= limit,
remaining: Math.max(0, limit - current),
resetAt: Math.ceil(Date.now() / 1000 / windowSeconds) * windowSeconds
};
}
Sliding Window Log
Track timestamps of each request; count within a sliding window.
Pros: Smooth rate limiting Cons: Memory-intensive for high traffic
async function slidingWindowLog(userId, limit, windowSeconds) {
const key = `ratelimit:${userId}`;
const now = Date.now();
const windowStart = now - (windowSeconds * 1000);
// Remove old entries and add a new one
const multi = redis.multi();
multi.zremrangebyscore(key, 0, windowStart);
multi.zadd(key, now, `${now}-${Math.random()}`);
multi.zcard(key);
multi.expire(key, windowSeconds);
const results = await multi.exec();
const count = results[2][1];
return {
allowed: count <= limit,
remaining: Math.max(0, limit - count),
count
};
}
Token Bucket
Tokens are added at a fixed rate; requests consume tokens.
Pros: Allows controlled bursts Cons: More complex implementation
async function tokenBucket(userId, bucketSize, refillRate, refillInterval) {
const key = `tokenbucket:${userId}`;
const now = Date.now();
// Get or initialize bucket
let bucket = await redis.hgetall(key);
if (!bucket.tokens) {
bucket = { tokens: bucketSize, lastRefill: now };
}
// Calculate tokens to add
const timePassed = now - parseInt(bucket.lastRefill);
const intervalsPasssed = Math.floor(timePassed / refillInterval);
const tokensToAdd = intervalsPasssed * refillRate;
const newTokens = Math.min(bucketSize, parseInt(bucket.tokens) + tokensToAdd);
// Try to consume a token
if (newTokens >= 1) {
await redis.hset(key, {
tokens: newTokens - 1,
lastRefill: bucket.lastRefill + (intervalsPasssed * refillInterval)
});
await redis.expire(key, 3600);
return { allowed: true, remaining: newTokens - 1 };
}
return { allowed: false, remaining: 0 };
}
Express.js Middleware
Basic Implementation
const rateLimit = require('express-rate-limit');
const RedisStore = require('rate-limit-redis');
const Redis = require('ioredis');
const redis = new Redis();
// Basic rate limiter
const limiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 100, // 100 requests per minute
standardHeaders: true, // Return rate limit info in headers
legacyHeaders: false,
message: {
error: 'Too many requests',
retryAfter: 60
}
});
app.use('/api', limiter);
Redis-Backed for Distributed Systems
const limiter = rateLimit({
windowMs: 60 * 1000,
max: 100,
standardHeaders: true,
store: new RedisStore({
sendCommand: (...args) => redis.call(...args),
prefix: 'rl:'
}),
keyGenerator: (req) => {
// Use user ID if authenticated; IP otherwise
return req.user?.id || req.ip;
},
skip: (req) => {
// Skip rate limiting for certain conditions
return req.user?.role === 'admin';
},
handler: (req, res) => {
res.status(429).json({
error: 'Rate limit exceeded',
retryAfter: Math.ceil(req.rateLimit.resetTime / 1000)
});
}
});
Multiple Rate Limiters
// Strict limiter for auth endpoints
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts per 15 minutes
message: { error: 'Too many login attempts' }
});
// Standard API limiter
const apiLimiter = rateLimit({
windowMs: 60 * 1000,
max: 100
});
// Generous limiter for read-only endpoints
const readLimiter = rateLimit({
windowMs: 60 * 1000,
max: 1000
});
// Apply to routes
app.use('/api/auth/login', authLimiter);
app.use('/api/auth/register', authLimiter);
app.get('/api/public/*', readLimiter);
app.use('/api', apiLimiter);
Laravel Rate Limiting
Route-Based Limiting
// routes/api.php
Route::middleware('throttle:60,1')->group(function () {
Route::get('/users', [UserController::class, 'index']);
Route::get('/posts', [PostController::class, 'index']);
});
// Strict limit for auth
Route::middleware('throttle:5,1')->group(function () {
Route::post('/login', [AuthController::class, 'login']);
});
Custom Rate Limiters
// app/Providers/RouteServiceProvider.php
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Support\Facades\RateLimiter;
public function boot()
{
$this->configureRateLimiting();
}
protected function configureRateLimiting()
{
// Default API limiter
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)
->by($request->user()?->id ?: $request->ip());
});
// Tiered limits based on plan
RateLimiter::for('api-tiered', function (Request $request) {
$user = $request->user();
if (!$user) {
return Limit::perMinute(10)->by($request->ip());
}
return match($user->plan) {
'enterprise' => Limit::none(),
'pro' => Limit::perMinute(1000)->by($user->id),
'basic' => Limit::perMinute(100)->by($user->id),
default => Limit::perMinute(30)->by($user->id),
};
});
// Multiple limits
RateLimiter::for('uploads', function (Request $request) {
return [
Limit::perMinute(10), // 10 per minute
Limit::perHour(100), // 100 per hour
Limit::perDay(500), // 500 per day
];
});
}
Apply Custom Limiters
Route::middleware('throttle:api-tiered')->group(function () {
Route::apiResource('users', UserController::class);
});
Route::middleware('throttle:uploads')->group(function () {
Route::post('/upload', [UploadController::class, 'store']);
});
Nginx Rate Limiting
http {
# Define rate limit zones
limit_req_zone $binary_remote_addr zone=api:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=login:10m rate=1r/s;
# Status code for rate limited requests
limit_req_status 429;
server {
location /api/ {
# Allow burst of 20, delay after 10
limit_req zone=api burst=20 delay=10;
proxy_pass http://backend;
}
location /api/auth/login {
# Strict: no burst, immediate rejection
limit_req zone=login burst=5 nodelay;
proxy_pass http://backend;
}
}
}
Response Headers
Standard headers to include:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 45
X-RateLimit-Reset: 1640000000
Retry-After: 60
// Express middleware to add headers
function rateLimitHeaders(req, res, next) {
const limit = 100;
const remaining = req.rateLimit?.remaining ?? limit;
const resetTime = req.rateLimit?.resetTime ?? Date.now() + 60000;
res.set({
'X-RateLimit-Limit': limit,
'X-RateLimit-Remaining': remaining,
'X-RateLimit-Reset': Math.ceil(resetTime / 1000)
});
next();
}
Client-Side Handling
async function apiCall(url, options = {}) {
const response = await fetch(url, options);
if (response.status === 429) {
const retryAfter = response.headers.get('Retry-After') || 60;
console.log(`Rate limited. Retry after ${retryAfter} seconds`);
// Exponential backoff
await new Promise(r => setTimeout(r, retryAfter * 1000));
return apiCall(url, options); // Retry
}
// Log remaining quota
const remaining = response.headers.get('X-RateLimit-Remaining');
if (remaining && parseInt(remaining) < 10) {
console.warn(`Low API quota: ${remaining} requests remaining`);
}
return response;
}
Best Practices
Identify Users Properly
function getUserIdentifier(req) {
// Prefer user ID for authenticated users
if (req.user?.id) {
return `user:${req.user.id}`;
}
// Use an API key if provided
const apiKey = req.headers['x-api-key'];
if (apiKey) {
return `apikey:${apiKey}`;
}
// Fall back to IP (consider X-Forwarded-For behind a proxy)
const ip = req.headers['x-forwarded-for']?.split(',')[0] || req.ip;
return `ip:${ip}`;
}
Different Limits for Different Operations
const rateLimits = {
'GET /api/users': { limit: 1000, window: 60 },
'POST /api/users': { limit: 10, window: 60 },
'DELETE /api/users': { limit: 5, window: 60 },
'POST /api/auth/login': { limit: 5, window: 900 },
'POST /api/upload': { limit: 10, window: 3600 }
};
Graceful Degradation
async function handleRequest(req, res) {
const { allowed, remaining } = await checkRateLimit(req);
if (!allowed) {
// Return cached response if available
const cached = await cache.get(req.url);
if (cached) {
res.set('X-Served-From-Cache', 'true');
return res.json(cached);
}
return res.status(429).json({
error: 'Rate limit exceeded',
message: 'Please try again later'
});
}
// Process normally
}
Key Takeaways
- Choose the right algorithm: Token bucket for APIs with burst allowance
- Use Redis for distributed systems: Share state across instances
- Return proper headers: Help clients manage their quota
- Tier by user/plan: Different limits for different customers
- Combine with Nginx: Double protection at application and proxy level
- Log and monitor: Track rate limit hits for capacity planning
Rate limiting is a balance between protection and user experience—too strict frustrates users, too lenient exposes vulnerabilities. Start conservative and adjust based on actual usage patterns.