Build an API Rate Limiter with Redis and Express (Step by Step)
Build an API rate limiter using Redis with a sliding window counter and token bucket algorithm, implemented as Express middleware with configurable limits per user or IP and standard rate-limit HTTP headers.
What You’ll Build
You’ll build two rate-limiting algorithms — sliding window counter and token bucket — as Express middleware backed by Redis. The middleware returns standard X-RateLimit-* headers, supports per-user and per-IP limits, and gracefully handles over-limit requests with 429 responses. This same rate-limiting logic protects DodaTech APIs, including DodaZIP’s conversion endpoints and Durga Antivirus Pro signature updates.
Why Rate Limiters Matter
Every public API needs rate limiting. Without it, a single user can monopolize resources, brute-force authentication, or trigger a Denial-of-Service (DoS) condition. Rate limiting is a fundamental security and reliability pattern. Companies like GitHub, Stripe, and Twitter all enforce limits. Building your own teaches you Redis, middleware patterns, and algorithm design in one project.
Prerequisites
- Node.js and Express.js basics
- Redis running locally (or via Docker)
- JavaScript ES6+ familiarity
Step 1: Project Setup
mkdir rate-limiter
cd rate-limiter
npm init -y
npm install express redis ioredis
npm install -D nodemonStart Redis (if not running):
docker run -d -p 6379:6379 redisStep 2: Redis Client Setup
// lib/redis.js
const Redis = require('ioredis');
const redis = new Redis({
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379,
retryStrategy: (times) => Math.min(times * 50, 2000),
});
redis.on('error', (err) => console.error('Redis error:', err));
redis.on('connect', () => console.log('Connected to Redis'));
module.exports = redis;Step 3: Sliding Window Counter
The sliding window algorithm uses a Redis sorted set. Each request adds a member with the current timestamp as score. Expired members (outside the window) are removed. The count is the size of the set.
// lib/slidingWindow.js
const redis = require('./redis');
/**
* Sliding window rate limiter
* @param {number} windowMs - time window in milliseconds
* @param {number} maxRequests - max requests allowed in the window
*/
function slidingWindow({ windowMs = 60000, maxRequests = 10 } = {}) {
return async (req, res, next) => {
const key = `ratelimit:sw:${req.ip}`;
// If user-specific, use a header or token
if (req.user?.id) {
key = `ratelimit:sw:user:${req.user.id}`;
}
const now = Date.now();
const windowStart = now - windowMs;
try {
// Remove entries outside the window
await redis.zremrangebyscore(key, 0, windowStart);
// Count remaining entries
const current = await redis.zcard(key);
// Add current request
await redis.zadd(key, now, `${now}-${Math.random()}`);
await redis.expire(key, Math.ceil(windowMs / 1000));
const remaining = Math.max(0, maxRequests - current - 1);
// Set rate limit headers
res.set('X-RateLimit-Limit', maxRequests.toString());
res.set('X-RateLimit-Remaining', remaining.toString());
res.set('X-RateLimit-Reset', Math.ceil((now + windowMs) / 1000).toString());
if (current >= maxRequests) {
return res.status(429).json({
error: 'Too many requests',
message: `Rate limit exceeded. Try again in ${Math.ceil(windowMs / 1000)} seconds.`,
retryAfter: Math.ceil(windowMs / 1000),
});
}
next();
} catch (err) {
console.error('Rate limiter error:', err);
next(); // Fail open: allow request if Redis is down
}
};
}
module.exports = slidingWindow;Expected output: The first 10 requests in any 60-second window from the same IP succeed. The 11th returns HTTP 429 with a clear error message. Headers show the client exactly how many requests remain.
Step 4: Token Bucket Algorithm
The token bucket allows bursts up to the bucket size, then refills at a steady rate.
// lib/tokenBucket.js
const redis = require('./redis');
/**
* Token bucket rate limiter using Lua script for atomicity
* @param {number} capacity - max tokens (burst size)
* @param {number} refillRate - tokens per second
*/
function tokenBucket({ capacity = 10, refillRate = 1 } = {}) {
const luaScript = `
local key = KEYS[1]
local now = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local refillRate = tonumber(ARGV[3])
local cost = tonumber(ARGV[4])
local bucket = redis.call('HGETALL', key)
local tokens
local lastRefill
if #bucket == 0 then
tokens = capacity
lastRefill = now
else
tokens = tonumber(bucket[2])
lastRefill = tonumber(bucket[4])
end
-- Refill tokens based on elapsed time
local elapsed = now - lastRefill
local refill = math.floor(elapsed * refillRate / 1000)
tokens = math.min(capacity, tokens + refill)
if tokens >= cost then
tokens = tokens - cost
redis.call('HMSET', key, 'tokens', tokens, 'lastRefill', now)
redis.call('EXPIRE', key, math.ceil(capacity / refillRate) + 1)
return {1, tokens, math.ceil((capacity - tokens) / refillRate)}
else
local retryAfter = math.ceil((cost - tokens) / refillRate)
return {0, tokens, retryAfter}
end
`;
return async (req, res, next) => {
const key = `ratelimit:tb:${req.ip}`;
try {
const result = await redis.eval(
luaScript,
1,
key,
Date.now(),
capacity,
refillRate,
1 // cost per request
);
const [allowed, tokens, retryAfter] = result;
res.set('X-RateLimit-Limit', capacity.toString());
res.set('X-RateLimit-Remaining', Math.max(0, Math.floor(tokens)).toString());
res.set('X-RateLimit-Reset', Math.ceil((Date.now() + retryAfter * 1000) / 1000).toString());
if (!allowed) {
return res.status(429).json({
error: 'Too many requests',
message: `Rate limit exceeded. Retry after ${retryAfter} seconds.`,
retryAfter,
});
}
next();
} catch (err) {
console.error('Token bucket error:', err);
next();
}
};
}
module.exports = tokenBucket;The Lua script ensures atomic operations — without it, two concurrent requests could both read the same token count and exceed the limit.
Step 5: Express Middleware Integration
// server.js
const express = require('express');
const slidingWindow = require('./lib/slidingWindow');
const tokenBucket = require('./lib/tokenBucket');
const app = express();
// Global rate limit: 60 requests per minute per IP
app.use('/api', slidingWindow({ windowMs: 60000, maxRequests: 60 }));
// Stricter limits for auth endpoints
app.use('/api/auth', slidingWindow({ windowMs: 60000, maxRequests: 5 }));
// Token bucket for burst-sensitive endpoints
app.use('/api/burst', tokenBucket({ capacity: 20, refillRate: 2 }));
app.get('/api/hello', (req, res) => {
res.json({ message: 'Hello! You are rate limited.' });
});
app.post('/api/auth/login', (req, res) => {
res.json({ message: 'Login endpoint — heavily rate limited' });
});
app.get('/api/burst/data', (req, res) => {
res.json({ message: 'Burst endpoint — token bucket allows spikes' });
});
app.use((err, req, res, next) => {
console.error(err.stack);
res.status(500).json({ error: 'Internal server error' });
});
app.listen(3000, () => {
console.log('Rate limited API running on http://localhost:3000');
});Step 6: Test the Rate Limiter
# First 5 auth requests succeed
for i in {1..6}; do
curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3000/api/auth/login
doneExpected output:
200
200
200
200
200
429Headers check:
curl -s -D - http://localhost:3000/api/hello | head -n 10Expected output:
HTTP/1.1 200 OK
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 59
X-RateLimit-Reset: 1687200000Architecture
sequenceDiagram
participant Client
participant Express as Express Middleware
participant Redis
participant API as Route Handler
Client->>Express: GET /api/resource
Express->>Redis: ZREMRANGEBYSCORE (clean old)
Express->>Redis: ZCARD (count requests)
Express->>Redis: ZADD (add current)
Express-->>Client: X-RateLimit headers
alt Under limit
Express->>API: next()
API-->>Client: 200 OK + response
else Over limit
Express-->>Client: 429 Too Many Requests
end
Note over Express,Redis: Token bucket uses Lua EVAL for atomicity
Common Errors
1. Redis connection refused
Redis is not running. Start with docker run -d -p 6379:6379 redis or install it natively. The retry strategy in lib/redis.js retries with backoff to handle temporary Redis restarts.
2. Rate limiter fires on every request (false positives)
The sorted set key uses req.ip. If your app is behind a reverse proxy (NGINX, Cloudflare), req.ip is the proxy’s IP. Fix: use req.headers['x-forwarded-for'] || req.ip. For Express, set app.set('trust proxy', true).
3. Lua script execution errors
Redis Lua scripts must return all values. If the script fails halfway (e.g., HMSET on a corrupted key), the entire script rolls back. Check Redis logs for script errors. The dead keys are automatically cleaned by EXPIRE.
4. Token bucket allows burst above capacity
The Redis hash stores tokens and lastRefill. On first request, tokens = capacity. After refill, tokens = min(capacity, tokens + refill). If the EXPIRE is shorter than the refill time, a cold bucket starts at full capacity — which is correct behavior.
5. Headers not sent when middleware errors
If Redis throws an error, the middleware logs it and calls next() (fail-open). Rate limit headers won’t be set for that request. This is intentional — the API stays available even if Redis is down. For strict enforcement, change next() to res.status(503).json(...).
Practice Questions
1. Why does the sliding window use a sorted set instead of a simple counter?
A sorted set allows precise per-request tracking. Each member has a timestamp, so expired entries can be removed with ZREMRANGEBYSCORE. A counter (INCR) would need separate logic for window boundaries and couldn’t handle uneven request distributions.
2. What is the purpose of the Lua script in the token bucket? Redis Lua scripts execute atomically — no other commands run between HGETALL and HMSET. Without it, two concurrent requests could both see 5 tokens remaining and both proceed, exceeding the limit. Atomicity prevents race conditions.
3. Why does the middleware “fail open” on Redis errors? Fail-open means the request proceeds even if the rate limiter has an error. This keeps the API available during Redis outages. Fail-closed would block all requests when Redis is down — a worse outcome for most APIs.
4. Challenge: Rate limit by API key
Extract x-api-key from the request header. Use it as part of the Redis key. Create a per-key limit. When an API key changes, reset the counter. This is how Stripe and GitHub implement per-token limits.
5. Challenge: Distributed rate limiting with sticky sessions
In a multi-server setup, requests from the same user might hit different servers. Redis is shared, so the sorted set approach works globally. But ensure all servers use the same windowMs config. Test by running two Express instances behind a load balancer.
FAQ
Next Steps
- Add Redis persistent storage for rate limit data
- Explore the Express.js middleware ecosystem
- Implement API key authentication with the JWT tutorial
- Check the Rate Limiting system design deep-dive
- Try the REST API project for more API patterns
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro