My API crashed last Tuesday because a client script went rogue and spammed my endpoints with 15,000 requests in under ten seconds. If you’re relying on basic rate limiting, a sudden burst of traffic right at the edge of your time window will still knock your server sideways. You need a sliding window rate limiter. It tracks requests in real-time instead of resetting on arbitrary, hardcoded hourly boundaries.
Let’s build a production-ready sliding window rate limiter in Node.js using Redis Sorted Sets. This setup handles concurrent requests, stops race conditions in their tracks, and keeps your Express APIs alive when traffic spikes.

The Problem with Fixed Windows (and Why Sliding Windows Rule)
Most basic rate limiters use a fixed window algorithm. Say your limit is 100 requests per minute. The counter resets at exactly 12:00:00, then 12:01:00, and so on. This creates a massive, easily exploitable gap.
An attacker can fire off 100 requests at 12:00:59 and another 100 requests at 12:01:01. Your rate limiter sees both windows as perfectly valid, but your server just took a beating of 200 requests in two seconds. This is the classic boundary burst problem.
A sliding window rate limiter fixes this by looking backward from the exact millisecond of the incoming request. If your window is 60 seconds, it only counts requests made in the last 60 seconds. It doesn’t care about calendar minutes—only the relative time window.
To build this efficiently, we’ll use Redis Sorted Sets (ZSETs). Redis is perfect here because it runs in-memory and handles atomic operations incredibly fast. It’s the same reason we use it for things like a Redis Distributed Lock in Node.js to keep backend state synchronized.
Setting Up the Local Environment
Before writing any JavaScript, we need a running Redis instance and a clean Node.js project. I run Redis via Docker because I hate polluting my local machine with background services.
Run this in your terminal to spin up a local Redis container:
docker run --name redis-limiter -p 6379:6379 -d redis
Once that’s running, initialize a new Node.js project in a clean directory. If you’re looking for a solid way to organize things, check out our guide on how to setup the best folder structure.
mkdir redis-rate-limiter
cd redis-rate-limiter
npm init -y
npm install express redis dotenv
Drop a .env file in your root directory to store the Redis connection details:
PORT=3000
REDIS_URL=redis://localhost:6379
The Math Behind the Sliding Window
To track requests in a sliding window, we treat each user’s IP or API key as a unique key in Redis. The value of this key is a Sorted Set. In a Redis Sorted Set, every element has an associated score. We’ll use Unix timestamps in milliseconds for both the element value and the score.
When a new request hits, we run four steps in Redis:
- Drop all elements from the set that are older than the current time minus the window size. We do this using ZREMRANGEBYSCORE.
- Grab the total number of elements left in the set using ZCARD.
- If the count is under our limit, add the current timestamp to the set using ZADD.
- Set an expiration time (TTL) on the entire key using EXPIRE so idle keys don’t eat up memory forever.
If the count is at or over our limit, we reject the request with a 429. This approach is incredibly fast and keeps your memory footprint low, similar to how we optimize memory when using Redis caching to speed up Node.js APIs.
Writing the Express Middleware with Node-Redis
Let’s build the actual middleware. Create a file named rateLimiter.js. We’ll use the official node-redis client to talk to our database.
Here’s the implementation:
const redis = require('redis'); const client = redis.createClient({ url: process.env.REDIS_URL || 'redis://localhost:6379'
}); client.on('error', (err) => console.error('Redis Client Error', err)); (async () => { await client.connect();
})(); const rateLimiter = (options) => { const { windowMs, maxLimit } = options; return async (req, res, next) => { const ip = req.ip || req.headers['x-forwarded-for']; const key = `rate_limit:${ip}`; const now = Date.now(); const clearBefore = now - windowMs; try { // Start a Redis transaction const [removed, count] = await client .multi() .zRemRangeByScore(key, 0, clearBefore) .zCard(key) .exec(); if (count < maxLimit) { await client.multi() .zAdd(key, { score: now, value: now.toString() }) .expire(key, Math.ceil(windowMs / 1000)) .exec(); res.setHeader('X-RateLimit-Limit', maxLimit); res.setHeader('X-RateLimit-Remaining', maxLimit - count - 1); return next(); } else { res.setHeader('X-RateLimit-Limit', maxLimit); res.setHeader('X-RateLimit-Remaining', 0); res.setHeader('Retry-After', Math.ceil(windowMs / 1000)); return res.status(429).json({ error: 'Too many requests, please try again later.' }); } } catch (err) { console.error('Rate limiter error:', err); // Fail open in production so rate limiter issues don't crash your app return next(); } };
}; module.exports = rateLimiter;
We wrap our Redis queries inside a transaction using .multi(). This ensures the cleanup and count operations execute sequentially without other Redis commands slipping in between. If you’re building high-volume apps, managing race conditions like this is just as critical as when you build a Redis-backed idempotency middleware.
Setting Up the Express Server
Let’s create a server.js file to wire up our middleware and test the rate limiting. We’ll set a limit of 5 requests per 10 seconds to make testing easy.
Here’s the server setup:
require('dotenv').config();
const express = require('express');
const rateLimiter = require('./rateLimiter'); const app = express();
const PORT = process.env.PORT || 3000; // Apply the rate limiter: 5 requests max per 10 seconds
const limiter = rateLimiter({ windowMs: 10000, maxLimit: 5
}); app.use(limiter); app.get('/api/resource', (req, res) => { res.json({ message: 'Success! You accessed the protected resource.' });
}); app.listen(PORT, () => { console.log(`Server running on port ${PORT}`);
});
Fire up your server by running this in your terminal:
node server.js
Testing the Rate Limiter (and Watching it Fail Under Load)
To verify that our sliding window works, we can use a quick bash loop to fire multiple requests in rapid succession. Open a new terminal window and run this:
for i in {1..7}; do curl -i http://localhost:3000/api/resource; echo ""; sleep 0.5; done
You should see the first five requests return a 200 OK response with decreasing X-RateLimit-Remaining headers. The sixth and seventh requests will immediately fail with a 429 Too Many Requests status.
If you wait 5 seconds and run the script again, you’ll notice that some slots have cleared up. Unlike a fixed window limiter where you have to wait for the top of the minute, the sliding window restores your capacity gradually as individual request timestamps fall outside the 10-second window.
Optimizing with Redis Transactions (Multi/Exec)
Our initial middleware implementation used two separate database round-trips: one transaction to clean and count, and a second transaction to add the new timestamp if the limit wasn’t exceeded.
Under heavy production load, this split logic can create a subtle race condition. If two requests from the same user hit two different server instances at the exact same millisecond, both might read a count below the limit before either has written its new timestamp. The user successfully bypasses your limit.
To fix this, we can perform all operations inside a single atomic script. The cleanest way to handle this in Redis is with a Lua script. Redis executes Lua scripts atomically on its main thread, guaranteeing that no other client can modify the data mid-execution.
Here’s how to rewrite the middleware using an atomic Lua script:
const redis = require('redis'); const client = redis.createClient({ url: process.env.REDIS_URL || 'redis://localhost:6379'
}); client.on('error', (err) => console.error('Redis Client Error', err)); (async () => { await client.connect();
})(); // Define the Lua script for atomic sliding window rate limiting
const slidingWindowScript = ` local key = KEYS[1] local now = tonumber(ARGV[1]) local window = tonumber(ARGV[2]) local limit = tonumber(ARGV[3]) local clearBefore = now - window redis.call('zremrangebyscore', key, 0, clearBefore) local currentRequests = redis.call('zcard', key) if currentRequests { const { windowMs, maxLimit } = options; return async (req, res, next) => { const ip = req.ip || req.headers['x-forwarded-for']; const key = `rate_limit:${ip}`; const now = Date.now(); try { // Execute the Lua script atomically inside Redis const result = await client.eval(slidingWindowScript, { keys: [key], arguments: [now.toString(), windowMs.toString(), maxLimit.toString()] }); const [allowed, remaining] = result; res.setHeader('X-RateLimit-Limit', maxLimit); res.setHeader('X-RateLimit-Remaining', remaining); if (allowed === 1) { return next(); } else { res.setHeader('Retry-After', Math.ceil(windowMs / 1000)); return res.status(429).json({ error: 'Too many requests, please try again later.' }); } } catch (err) { console.error('Lua rate limiter error:', err); return next(); } };
}; module.exports = rateLimiterLua;
By moving the logic into a Lua script, we eliminated the extra network round-trip and made the entire check-and-set operation completely atomic. If you’re handling high-throughput external webhooks, combining this with a queuing strategy like the one in our guide on handling external API rate limits with BullMQ will keep your infrastructure incredibly stable.
Production Gotchas and Edge Cases
Building a rate limiter on your local machine is easy, but running it at scale introduces unique operational challenges. Here are the issues I’ve run into when deploying this system to production:
Memory Consumption of Sorted Sets
Unlike simple string counters, Sorted Sets consume a significant amount of memory in Redis. Every request adds a new member to the set. If you have millions of active users making hundreds of requests per minute, your Redis memory usage will spike quickly.
To mitigate this, always set an explicit TTL on your keys using the EXPIRE command. Additionally, configure your Redis instance with an appropriate eviction policy, such as volatile-lru (Least Recently Used), so Redis automatically drops expired rate-limiting keys if it runs out of physical memory.
Clock Drift Across Servers
Because our sliding window algorithm relies on Date.now() inside your Node.js application, server clock drift can cause inconsistent rate limiting. If Server A’s clock is 200 milliseconds ahead of Server B’s clock, requests routed across different servers might be incorrectly blocked or allowed.
To avoid this, use Network Time Protocol (NTP) to synchronize your application server clocks, or fetch the current time directly from Redis using the TIME command inside your Lua script instead of passing the client-side timestamp.
Frequently Asked Questions
What happens if the Redis server goes down?
In our implementation, we wrapped the Redis operations inside a try/catch block and called next() if an error occurred. This is known as 'failing open.' It's usually better to let traffic pass through if your rate limiter fails than to block 100% of your legitimate users. If you need strict protection, you can change this to 'fail closed' and return a 503 Service Unavailable error instead.
Should I use this middleware on my asset routes?
No. Don’t run rate limiting middleware on static assets (images, CSS, JS files). It wastes precious Redis CPU cycles. Only apply rate limiting to dynamic API routes and sensitive endpoints like login, signup, and checkout actions.
How does this compare to token bucket rate limiting?
The sliding window algorithm is highly accurate but has a higher memory footprint because it stores individual timestamps. The token bucket algorithm uses a single Redis hash to store a balance and a last-updated timestamp, which uses less memory but is slightly more complex to calculate atomically.
Next Steps
Now that you’ve built an atomic, reliable rate limiter, you can expand your API security. If your application handles external webhooks or payment notifications, look at how to build a webhook retry system with exponential backoff in Node.js to process incoming events reliably without overloading your backend services.

