Why Rate Limit?
Rate limiting prevents abuse, protects backend resources, ensures fair usage across clients, and stops runaway scripts from overwhelming your API. Without it, a single aggressive client can degrade service for everyone. Every production API needs rate limiting — the question is which algorithm and where to implement it.
Express: Basic Rate Limiting
The express-rate-limit package is the fastest way to add rate limiting to Express apps. It uses an in-memory store by default.
const rateLimit = require('express-rate-limit');
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per window
standardHeaders: true, // Return rate limit info in `RateLimit-*` headers
legacyHeaders: false,
message: { error: 'Too many requests, please try again later.' },
keyGenerator: (req) => req.headers['x-api-key'] || req.ip // Rate limit by API key first
});
app.use('/api/', apiLimiter);
Sliding Window Algorithm
Fixed windows have a burst problem — a client can make 100 requests at the end of one window and 100 at the start of the next (200 in seconds). Sliding window smooths this by weighting the previous window.
class SlidingWindowLimiter {
constructor(windowMs, maxRequests) {
this.windowMs = windowMs;
this.maxRequests = maxRequests;
this.windows = new Map(); // key -> { current, previous, currentStart }
}
isAllowed(key) {
const now = Date.now();
const windowStart = Math.floor(now / this.windowMs) * this.windowMs;
if (!this.windows.has(key)) {
this.windows.set(key, { current: 0, previous: 0, currentStart: windowStart });
}
const record = this.windows.get(key);
// Rotate windows
if (record.currentStart !== windowStart) {
record.previous = record.currentStart === windowStart - this.windowMs ? record.current : 0;
record.current = 0;
record.currentStart = windowStart;
}
// Weighted count: previous window weight + current window count
const elapsed = (now - windowStart) / this.windowMs;
const weight = record.previous * (1 - elapsed) + record.current;
if (weight >= this.maxRequests) return false;
record.current++;
return true;
}
}
Redis: Distributed Rate Limiting
In-memory limiters fail with multiple server instances — each sees only its own traffic. Redis provides a shared counter across all instances.
const Redis = require('ioredis');
const redis = new Redis();
async function redisRateLimit(key, maxRequests, windowSec) {
const luaScript = `
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return current
`;
const count = await redis.eval(luaScript, 1, `rl:${key}`, windowSec);
return {
allowed: count <= maxRequests,
remaining: Math.max(0, maxRequests - count),
resetAt: Date.now() + windowSec * 1000
};
}
// Express middleware
async function rateLimitMiddleware(req, res, next) {
const key = req.headers['x-api-key'] || req.ip;
const result = await redisRateLimit(key, 100, 900); // 100 per 15 min
res.set('RateLimit-Limit', '100');
res.set('RateLimit-Remaining', String(result.remaining));
res.set('RateLimit-Reset', String(Math.ceil(result.resetAt / 1000)));
if (!result.allowed) {
return res.status(429).json({ error: 'Rate limit exceeded' });
}
next();
}
Tiered Rate Limits by Plan
SaaS APIs typically have different rate limits per pricing tier. Combine API key lookup with Redis rate limiting for per-plan enforcement.
const PLAN_LIMITS = {
free: { rpm: 10, daily: 200 },
starter: { rpm: 60, daily: 5000 },
pro: { rpm: 300, daily: 50000 },
business:{ rpm: 1000, daily: 500000 }
};
async function tieredRateLimit(req, res, next) {
const apiKey = req.headers['x-api-key'];
if (!apiKey) return res.status(401).json({ error: 'API key required' });
const user = await getUserByApiKey(apiKey);
if (!user) return res.status(401).json({ error: 'Invalid API key' });
const limits = PLAN_LIMITS[user.plan] || PLAN_LIMITS.free;
// Check per-minute limit
const minuteResult = await redisRateLimit(`${apiKey}:min`, limits.rpm, 60);
if (!minuteResult.allowed) {
res.set('Retry-After', '60');
return res.status(429).json({
error: 'Rate limit exceeded',
limit: limits.rpm,
plan: user.plan,
upgrade: 'https://snapapi.pics/dashboard.html#billing'
});
}
// Check daily limit
const dailyResult = await redisRateLimit(`${apiKey}:day`, limits.daily, 86400);
if (!dailyResult.allowed) {
return res.status(429).json({ error: 'Daily quota exceeded', limit: limits.daily });
}
req.user = user;
req.rateLimit = { remaining: minuteResult.remaining, daily: dailyResult.remaining };
next();
}
This is exactly how SnapAPI handles rate limiting — per-plan quotas with Redis-backed distributed counting across cluster instances. The RateLimit-* headers tell clients their current state without extra API calls.