How to Implement Redis Token Bucket Rate Limiting in Node.js (2026 Guide)
As of March 2026, API rate limiting remains one of the most critical components of production API design. Whether you're protecting against abuse, ensuring fair usage, or preventing downstream system failures, a robust rate limiting strategy is essential. In this guide, we'll implement a production-ready token bucket rate limiter using Redis and Node.js.
Why Token Bucket?
The token bucket algorithm has become the preferred choice for modern APIs because it handles bursty traffic elegantly. Unlike simple fixed-window limiters that can allow traffic spikes at window boundaries, token bucket allows controlled bursts while maintaining a consistent long-term average.
Key advantages:
- Burst-friendly: Clients can send multiple requests at once if they've accumulated tokens
- Smooth enforcement: No sharp cutoffs at window boundaries
- Flexible configuration: Separate controls for burst capacity and refill rate
Prerequisites
npm install express ioredis lua
We'll use:
- Express.js (v4.21) - Web framework
- ioredis (v5.x) - Redis client
- Lua scripts - For atomic operations
The Architecture
Here's how the token bucket works:
- Each client has a "bucket" with tokens (max capacity = burst limit)
- Tokens refill at a steady rate (refill rate)
- Each request consumes 1 token
- If bucket is empty → 429 Too Many Requests
Implementation
1. Redis Lua Script (Atomic Operations)
The key to a reliable rate limiter is atomicity. We'll use a Lua script to ensure token checking and consumption happens in a single Redis operation:
// rate-limiter.lua
local key = KEYS[1]
local now = tonumber(ARGV[1])
local capacity = tonumber(ARGV[2])
local refillRate = tonumber(ARGV[3])
local bucket = redis.call('HMGET', key, 'tokens', 'lastRefill')
local tokens = tonumber(bucket[1])
local lastRefill = tonumber(bucket[2])
-- Initialize if doesn't exist
if tokens == nil then
tokens = capacity
lastRefill = now
end
-- Calculate token refill
local elapsed = now - lastRefill
local refillAmount = elapsed * refillRate
tokens = math.min(capacity, tokens + refillAmount)
lastRefill = now
-- Check if request is allowed
local allowed = false
if tokens >= 1 then
tokens = tokens - 1
allowed = true
end
-- Save back to Redis with 24h expiry
redis.call('HMSET', key, 'tokens', tokens, 'lastRefill', lastRefill)
redis.call('EXPIRE', key, 86400)
return {allowed and 1 or 0, tokens}
2. Rate Limiter Class
// rate-limiter.js
const Redis = require('ioredis');
const fs = require('fs');
const path = require('path');
class TokenBucketRateLimiter {
constructor(options = {}) {
this.redis = options.redis || new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
this.capacity = options.capacity || 100; // Max tokens in bucket
this.refillRate = options.refillRate || 10; // Tokens per second
this.keyPrefix = options.keyPrefix || 'ratelimit';
// Load Lua script
this.luaScript = fs.readFileSync(
path.join(__dirname, 'rate-limiter.lua'),
'utf8'
);
}
/**
* Check if request is allowed and consume a token
* @param {string} identifier - Client identifier (IP, userId, API key)
* @returns {Promise<{allowed: boolean, remaining: number, resetIn: number}>}
*/
async check(identifier) {
const key = `${this.keyPrefix}:${identifier}`;
const now = Date.now();
const result = await this.redis.eval(
this.luaScript,
1, // Number of keys
key, // Key name
now, // ARGV[1]: current timestamp
this.capacity, // ARGV[2]: bucket capacity
this.refillRate / 1000 // ARGV[3]: refill rate per ms
);
const allowed = result[0] === 1;
const remaining = Math.floor(result[1]);
// Calculate reset time (time to get 1 token when bucket is empty)
const resetIn = allowed ? 0 : Math.ceil((1 - remaining) / (this.refillRate / 1000) * 1000);
return {
allowed,
remaining: Math.max(0, remaining),
resetIn,
limit: this.capacity
};
}
/**
* Express middleware factory
*/
middleware(options = {}) {
const limiter = this;
const {
getIdentifier = (req) => req.ip || req.connection.remoteAddress,
skipSuccessfulRequests = false,
skipFailedRequests = false
} = options;
return async (req, res, next) => {
const identifier = getIdentifier(req);
const result = await limiter.check(identifier);
// Set rate limit headers
res.set({
'RateLimit-Limit': result.limit,
'RateLimit-Remaining': result.remaining,
'RateLimit-Reset': Math.ceil(result.resetIn / 1000)
});
if (!result.allowed) {
res.set('Retry-After', Math.ceil(result.resetIn / 1000));
return res.status(429).json({
error: 'Too Many Requests',
message: 'Rate limit exceeded. Please try again later.',
retryAfter: result.resetIn
});
}
next();
};
}
async close() {
await this.redis.quit();
}
}
module.exports = TokenBucketRateLimiter;
3. Express Server Integration
// server.js
const express = require('express');
const TokenBucketRateLimiter = require('./rate-limiter');
const app = express();
const PORT = process.env.PORT || 3000;
// Create rate limiter instance
const limiter = new TokenBucketRateLimiter({
capacity: 100, // Allow 100 requests
refillRate: 10, // Refill 10 tokens per second
keyPrefix: 'api'
});
// Apply to all routes
app.use(limiter.middleware({
getIdentifier: (req) => {
// Use API key if available, otherwise fall back to IP
return req.headers['x-api-key'] || req.ip;
}
}));
// Example endpoints
app.get('/api/data', (req, res) => {
res.json({ message: 'Data retrieved successfully' });
});
app.post('/api/submit', (req, res) => {
res.json({ message: 'Data submitted successfully' });
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT}`);
});
Advanced: Endpoint-Specific Limits
Different endpoints often need different limits:
const rateLimitConfigs = {
'/api/auth/login': { capacity: 5, refillRate: 1 }, // Strict: 5/min
'/api/data': { capacity: 100, refillRate: 10 }, // Normal: 100/s
'/api/reports': { capacity: 10, refillRate: 1 } // Heavy: 10/s
};
app.use((req, res, next) => {
const path = req.path;
const config = rateLimitConfigs[path] || { capacity: 100, refillRate: 10 };
const limiter = new TokenBucketRateLimiter({
...config,
keyPrefix: `api:${path}`
});
limiter.middleware({ getIdentifier: (req) => req.ip })(req, res, next);
});
Best Practices for 2026
- Fail Open: If Redis goes down, allow requests rather than blocking all traffic
-
Use Standard Headers: Always include
RateLimit-*headers for client visibility - Log & Monitor: Track rate limit hits to detect abuse patterns
- Separate Limits by Tier: Free users get stricter limits than paid users
Conclusion
Token bucket rate limiting with Redis provides a robust, scalable solution for API traffic management. The Lua script ensures atomic operations under high concurrency, and the modular design allows easy integration with any Express application.
At 1xAPI, we understand the importance of rate limiting for API reliability. Whether you're building public APIs or internal services, implementing proper rate limiting protects your infrastructure while providing a fair experience for all users.
Top comments (0)