Building Custom Rate Limiters Using Redis and Nodejs
What You’ll Need
- Hetzner VPS or Contabo VPS for hosting high-performance Node.js and Redis instances
- DigitalOcean as an alternative managed cloud infrastructure option
- Node.js version 18.x or higher installed on your system
- A running Redis server instance version 6.x or higher
Table of Contents
- Understanding Rate Limiting Algorithms
- Setting Up Nodejs and Redis Connection
- Building a Sliding Window Rate Limiter
- Implementing Atomic Token Bucket with Lua Scripts
- Handling Distributed Architecture and Edge Cases
- Getting Started
Understanding Rate Limiting Algorithms
Rate limiting is an essential defense mechanism for backend systems. When I build public APIs or internal microservices, controlling the velocity of incoming traffic protects database clusters from exhaustion, mitigates denial of service attacks, and ensures fair resource distribution across tenants.
Before touching code, we must evaluate the core algorithms used for API rate limiting:
- Fixed Window Counter: Divides time into fixed intervals (for example, 60-second windows). A counter increments per request. While simple, it suffers from boundary bursts where a user can send double the limit across the window boundary.
- Sliding Window Log: Logs the timestamp of every request in a sorted set. To evaluate a request, it removes timestamps older than the window frame and counts the remaining items. This is accurate but consumes significant memory.
- Sliding Window Counter: Blends the fixed window memory efficiency with the sliding log accuracy by using a weighted average of current and previous window counts.
- Token Bucket: Refills tokens at a steady rate into a bucket up to a maximum capacity. Each incoming request consumes a token. This handles bursts of traffic gracefully up to the maximum capacity while maintaining a steady consumption ceiling.
In high traffic microservices, using application instance memory for rate limiting creates inconsistencies when requests hit round-robin load balancers. Offloading state to Redis provides centralized, fast, persistent storage across distributed application nodes.
When protecting public endpoints, application-level rate limiting works best alongside network-level security. Combining application limiters with system tools like Configuring Fail2ban Security for Linux VPS allows you to block abusive client IPs at the OS firewall before they consume Node.js CPU cycles.
Setting Up Nodejs and Redis Connection
To demonstrate production ready rate limiters, we will set up a Express backend coupled with ioredis. I prefer using ioredis because it provides native support for Redis Cluster, pipeline execution, and Lua script evaluation with built-in promises.
If you are spinning up fresh infrastructure, provisioning a instance on Hetzner VPS or DigitalOcean provides low network latency between your Node.js application process and your Redis daemon.
First, create a project folder and install the required dependencies:
mkdir redis-rate-limiter
cd redis-rate-limiter
npm init -y
npm install express ioredis dotenv
npm install --save-dev nodemon
Next, configure the Redis client module. Create a file named redisClient.js:
const Redis = require('ioredis');
const redisHost = process.env.REDIS_HOST || '127.0.0.1';
const redisPort = process.env.REDIS_PORT || 6379;
const redis = new Redis({
host: redisHost,
port: Number(redisPort),
enableOfflineQueue: false,
maxRetriesPerRequest: 3,
retryStrategy(times) {
const delay = Math.min(times * 50, 2000);
return delay;
}
});
redis.on('connect', () => {
console.log('Successfully connected to Redis database instance.');
});
redis.on('error', (err) => {
console.error('Redis connection error observed:', err.message);
});
module.exports = redis;
💡 Fast-Track Your Project: Don’t want to configure this yourself? I build custom n8n pipelines and bots. Message me with code SYS3-HUGO.
Building a Sliding Window Rate Limiter
The Sliding Window Log algorithm implemented via Redis Sorted Sets (ZSET) provides fine-grained precision. Every time a client hits your endpoint, we log their Unix timestamp in milliseconds as both the score and member value in a sorted set.
Here is how the Redis execution works:
- Remove all records in the sorted set with scores older than
currentTime - windowSize. - Fetch the total remaining count of elements in the set.
- Add the current timestamp to the sorted set.
- Set an expiration on the key matching the window duration to prevent memory leakage.
Create a middleware file named slidingWindowLimiter.js:
const redis = require('./redisClient');
function createSlidingWindowLimiter(options) {
const windowMs = options.windowMs || 60000;
const maxRequests = options.maxRequests || 10;
return async function slidingWindowMiddleware(req, res, next) {
const clientId = req.ip || req.headers['x-forwarded-for'] || 'global';
const key = `ratelimit:sliding:${clientId}`;
const now = Date.now();
const clearBefore = now - windowMs;
try {
const pipeline = redis.pipeline();
pipeline.zremrangebyscore(key, 0, clearBefore);
pipeline.zcard(key);
pipeline.zadd(key, now, `${now}:${Math.random()}`);
pipeline.expire(key, Math.ceil(windowMs / 1000));
const results = await pipeline.exec();
const currentRequestCount = results[1][1];
if (currentRequestCount >= maxRequests) {
res.setHeader('X-RateLimit-Limit', maxRequests);
res.setHeader('X-RateLimit-Remaining', 0);
res.setHeader('Retry-After', Math.ceil(windowMs / 1000));
return res.status(429).json({
status: 'error',
statusCode: 429,
message: 'Too many requests generated from this client IP address. Please slow down.'
});
}
const remaining = Math.max(0, maxRequests - (currentRequestCount + 1));
res.setHeader('X-RateLimit-Limit', maxRequests);
res.setHeader('X-RateLimit-Remaining', remaining);
return next();
} catch (error) {
console.error('Sliding window evaluation failure:', error);
return next();
}
};
}
module.exports = createSlidingWindowLimiter;
If your application interacts with dynamic client IPs or scraped targets, such as systems used when Managing Dynamic Proxy Pools Using Python and Redis, tracking solely by client IP can lead to false positives. In those cases, you should switch the clientId key to extract an API Key or Bearer Token from headers.
Now create an Express server file named server.js to test this middleware:
const express = require('express');
const createSlidingWindowLimiter = require('./slidingWindowLimiter');
const app = express();
app.use(express.json());
const apiLimiter = createSlidingWindowLimiter({
windowMs: 60000,
maxRequests: 5
});
app.get('/api/v1/resource', apiLimiter, (req, res) => {
return res.status(200).json({
status: 'success',
data: 'Access granted to sensitive protected resource.'
});
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Express web server running on port ${PORT}`);
});
Implementing Atomic Token Bucket with Lua Scripts
While the sliding window log is accurate, storing every timestamp in a Redis sorted set consumes memory under high volume. The Token Bucket algorithm offers better scalability: it updates a single hash containing the available token balance and the last refresh timestamp.
However, performing multi-step read and update operations from Node.js can cause race conditions under high concurrency. We ensure absolute atomicity by defining the logic inside a custom Lua script, which Redis executes in a single block without interruption.
Create a file named tokenBucketLimiter.js:
const redis = require('./redisClient');
const tokenBucketLuaScript = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refillRate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local bucket = redis.call('HMGET', key, 'tokens', 'lastRefill')
local tokens = tonumber(bucket[1])
local lastRefill = tonumber(bucket[2])
if not tokens then
tokens = capacity
lastRefill = now
else
local delta = math.max(0, now - lastRefill)
local tokensToAdd = (delta / 1000) * refillRate
tokens = math.min(capacity, tokens + tokensToAdd)
lastRefill = now
end
if tokens >= requested then
tokens = tokens - requested
redis.call('HMSET', key, 'tokens', tokens, 'lastRefill', lastRefill)
local ttl = math.ceil(capacity / refillRate) * 2
redis.call('EXPIRE', key, ttl)
return {1, math.floor(tokens)}
else
redis.call('HMSET', key, 'tokens', tokens, 'lastRefill', lastRefill)
return {0, math.floor(tokens)}
end
`;
redis.defineCommand('evaluateTokenBucket', {
numberOfKeys: 1,
lua: tokenBucketLuaScript
});
function createTokenBucketLimiter(options) {
const capacity = options.capacity || 10;
const refillRate = options.refillRate || 1;
return async function tokenBucketMiddleware(req, res, next) {
const clientId = req.ip || req.headers['x-forwarded-for'] || 'global';
const key = `ratelimit:bucket:${clientId}`;
const now = Date.now();
const requestedTokens = 1;
try {
const response = await redis.evaluateTokenBucket(
key,
capacity,
refillRate,
now,
requestedTokens
);
const allowed = response[0] === 1;
const remainingTokens = response[1];
res.setHeader('X-RateLimit-Limit', capacity);
res.setHeader('X-RateLimit-Remaining', remainingTokens);
if (!allowed) {
res.setHeader('Retry-After', Math.ceil(1 / refillRate));
return res.status(429).json({
status: 'error',
statusCode: 429,
message: 'Token bucket exhausted. Request quota exceeded.'
});
}
return next();
} catch (error) {
console.error('Token bucket processing error:', error);
return next();
}
};
}
module.exports = createTokenBucketLimiter;
Update your server.js file to compare both implementations:
const express = require('express');
const createSlidingWindowLimiter = require('./slidingWindowLimiter');
const createTokenBucketLimiter = require('./tokenBucketLimiter');
const app = express();
app.use(express.json());
const slidingLimiter = createSlidingWindowLimiter({
windowMs: 60000,
maxRequests: 5
});
const tokenBucketLimiter = createTokenBucketLimiter({
capacity: 10,
refillRate: 2
});
app.get('/api/v1/sliding', slidingLimiter, (req, res) => {
return res.status(200).json({
status: 'success',
message: 'Sliding window endpoint cleared successfully.'
});
});
app.get('/api/v1/bucket', tokenBucketLimiter, (req, res) => {
return res.status(200).json({
status: 'success',
message: 'Token bucket endpoint cleared successfully.'
});
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server actively running on port ${PORT}`);
});
Handling Distributed Architecture and Edge Cases
When running custom rate limiters in production environments, you will inevitably hit system edge cases. Here is how I address the most common failures:
Fail Open Strategy
If Redis suffers a network interruption or node restart, your API middleware should not crash or block legitimate traffic. Notice the try/catch blocks in both middleware examples above catch execution errors and trigger next(). This pattern is called failing open. It prioritizes service availability over strict rate limit enforcement during infrastructure degradation.
Key TTL Management
Forgetting to attach expiration timeouts to rate limiting keys in Redis leads to memory leaks. Always set an EXPIRE relative to your sliding window frame size or token refill duration during pipeline execution.
Automated Server Maintenance
Running Redis alongside background workers requires regular monitoring of memory utilization and key eviction counts. You can automate cluster health notifications and cleanup tasks by Scheduling Background Python Scripts with Systemd Timers on your host machine to audit key distribution safely without blocking Node.js application threads.
Getting Started
Building rate limiters with Redis and Node.js allows you to control traffic precisely while keeping your application stateless and horizontally scalable. Start by deploying your Redis instance on reliable infrastructure like Hetzner VPS, Contabo VPS, or DigitalOcean. Choose the Sliding Window Log approach for absolute window accuracy, or use the Lua-backed Token Bucket for high-throughput, low-memory performance.
Outsource Your Automation
Don’t have time? I build production n8n workflows, WhatsApp bots, and fully automated YouTube Shorts pipelines. Hire me on Fiverr, mention SYS3-HUGO for priority. Or DM at chasebot.online.
Want to automate this yourself?
Start with n8n Cloud (free tier available) or self-host on a Hetzner VPS for full control.
Want this engine running on your own VPS?
This blog publishes itself — daily, unattended, on free API tiers. The full engine, Hugo theme, and setup guide are available as System 3.
Get System 3