The Hidden Trap of Fixed Windows
Most developers implement rate limiting using the Fixed Window algorithm. The logic is straightforward: you allow 100 requests per minute, and at the start of every new minute, the counter resets to zero. While simple to code, this approach has a dangerous flaw known as the ‘boundary problem.’
Consider a user who sends 100 requests at 10:00:59 and another 100 at 10:01:01. Your system sees two separate minutes and allows all 200 requests. In practice, your server just endured a massive burst of 200 hits in only two seconds. I’ve seen production databases lock up because a ‘100 requests per minute’ limit allowed a spike that should have been blocked. Mastering rolling windows is what keeps your infrastructure alive during these sudden traffic surges.
Why the Sliding Window Log Wins
The Sliding Window Log algorithm eliminates the boundary problem by tracking every individual request timestamp. Instead of one global counter, we maintain a history (or log) for each user. When a new request hits your API, the system follows three steps:
- It purges all timestamps older than the current window (e.g., anything older than 60 seconds).
- It adds the current request’s timestamp to the log.
- It counts the remaining entries. If the count is within the limit, the request proceeds.
Since the window is calculated relative to the exact millisecond of the request, there are no fixed reset points. With a 100-request limit, the user is strictly capped across any 60-second slice of time, regardless of when they start clicking.
Prerequisites and Environment Setup
In a distributed environment, you need a shared data store so all your API instances stay in sync. Redis is the industry standard here. Its Sorted Sets (ZSET) are ideal for managing timestamp logs with high performance. We will use Node.js and the ioredis library for the implementation.
Start by setting up your project directory:
mkdir redis-rate-limiter
cd redis-rate-limiter
npm init -y
npm install ioredis
If you don’t have Redis installed locally, you can spin up a container in seconds using Docker:
docker run -d --name redis-limiter -p 6379:6379 redis
Implementing the Rate Limiter Logic
We will leverage Redis Sorted Sets where every element has a score. By using the Unix timestamp as both the value and the score, we can query and prune old data points with microsecond precision. This class-based approach makes the logic easy to drop into any Express or Fastify route.
const Redis = require('ioredis');
const redis = new Redis({
host: '127.0.0.1',
port: 6379,
});
class RateLimiter {
constructor(limit, windowInSeconds) {
this.limit = limit;
this.windowInMs = windowInSeconds * 1000;
}
async isAllowed(userId) {
const key = `rate_limit:${userId}`;
const now = Date.now();
const windowStart = now - this.windowInMs;
// Using a pipeline ensures atomicity and cuts network latency
const multi = redis.multi();
// 1. Clean up: Remove timestamps older than our rolling window
multi.zremrangebyscore(key, 0, windowStart);
// 2. Log the current attempt
multi.zadd(key, now, now);
// 3. Retrieve the current count for this user
multi.zcard(key);
// 4. Set TTL so idle users don't waste Redis memory
multi.expire(key, Math.ceil(this.windowInMs / 1000) + 1);
const results = await multi.exec();
// ioredis returns results in the format: [[err, result], ...]
const requestCount = results[2][1];
return {
allowed: requestCount <= this.limit,
count: requestCount
};
}
}
module.exports = RateLimiter;
Inside the Redis Commands
zremrangebyscore: This is the engine of the sliding window. It clears out “expired” requests that happened before the current time minus the window duration.zadd: This records the current hit. Even if the request is eventually blocked, logging the attempt prevents users from spamming the boundary.zcard: This returns the total count of valid timestamps remaining in the set.multi.exec(): This wraps everything in a transaction. It prevents race conditions where two simultaneous requests might miscalculate the count.
Verification and Monitoring
To see the limiter in action, let’s hook it up to a simple Express server. Install the framework with npm install express and create a server.js file.
const express = require('express');
const RateLimiter = require('./limiter');
const app = express();
const limiter = new RateLimiter(5, 10); // Allow 5 requests every 10 seconds
app.get('/api/resource', async (req, res) => {
const userId = req.query.user || 'anonymous';
const { allowed, count } = await limiter.isAllowed(userId);
if (!allowed) {
return res.status(429).json({
error: 'Too Many Requests',
currentCount: count,
limit: 5
});
}
res.json({ message: 'Success!', currentCount: count });
});
app.listen(3000, () => console.log('Server running on port 3000'));
Testing the Sliding Logic
Fire off seven requests rapidly using this bash loop. You will see the first five succeed, while the last two get blocked immediately.
for i in {1..7}; do curl "http://localhost:3000/api/resource?user=dev_user"; echo ""; done
Operational Costs
Sliding Window Log offers superior accuracy, but it trades off memory for that precision. A single ZSET entry in Redis consumes roughly 60 to 100 bytes. If you track 1,000 requests for 10,000 active users, you might use up to 1GB of RAM just for rate limiting. Always monitor your usage with INFO memory.
If memory becomes a bottleneck, you can explore the Sliding Window Counter. It’s a hybrid approach that uses less RAM but introduces a small margin of error. For most high-security APIs, however, the ZSET method is the gold standard.
Summary
Switching from Fixed Window to Sliding Window Log removes the risk of users doubling their throughput at the edge of a minute. By using Redis, your rate limits remain consistent even if you scale to 50 Node.js instances behind a load balancer. This setup provides a professional-grade foundation for protecting your backend from both accidental spikes and malicious abuse.

