Build a Distributed Rate Limiter
Build a Redis-backed rate limiter with sliding window algorithms in Node.js.
Introduction
30 Second Summary
Every API you've ever used has a hidden guard at the door that blocks you if you send too many requests too fast. Building that guard from scratch is one of the most commonly asked system design interview questions at top tech companies.
In this project, you will build a distributed rate limiter from scratch using Node.js, TypeScript, Express, and Redis. You will start with a simple counter that works on one server, discover its critical flaws firsthand, then replace it with a production-grade solution that enforces limits across multiple server instances.
What You'll Build
You fire 20 concurrent requests across two separate server instances, and your rate limiter holds the line at exactly 10 allowed, proving the distributed limit works under real pressure.
By the end of this project, you'll have:
- Two rate-limiting algorithms you built and tested yourself, giving you firsthand experience to walk through fixed-window flaws and sliding-window fixes in any interview.
- A concurrent load test that fires 20 parallel requests across two server instances and proves your distributed limiter holds the global limit with zero race conditions.
- Production-ready middleware with standard rate-limit headers on every response and graceful degradation when Redis goes down.
- Secret Mission: Implement a sliding window log with Redis sorted sets and compare its memory cost against the counter approach.
Are there any prerequisites?
This project assumes you're comfortable writing basic Node.js and TypeScript code and have worked with Express middleware before. You'll also need Docker installed on your machine to run a Redis container.
Before We Start
Before diving into code, let's lock in what you're about to build and why it matters.
Set Up the Express + TypeScript Project
Before you can rate-limit anything, you need an API server with endpoints worth protecting. A rate limiter sits in front of your routes and decides whether each incoming request gets through or gets blocked.
In this step, you will scaffold an Express project with TypeScript and create two API endpoints. Once the server is running, you will have a live target to attach your rate limiter to in the next step.
In this step, get ready to:
- Initialize a Node.js project with Express and TypeScript dependencies.
- Create a TypeScript compiler configuration.
- Build an Express server with two API routes and verify it responds.
Initialize the project and install dependencies
Every Node.js project starts with a directory and a package.json file that defines its dependencies and scripts.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal and press Enter to open it.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir distributed-rate-limiter
cd distributed-rate-limiter
- Confirm the folder exists by running:
ls
The folder is empty for now. You will fill it with configuration files next.
Instead of using npm init, you will create package.json directly so it includes the exact dependencies and convenience scripts the project needs.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code (or your code editor's name) and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Select the distributed-rate-limiter folder on your Desktop, then click Open.
- Create a new file called package.json by clicking the new file icon in the Explorer sidebar.
- Paste the following configuration into package.json:
{
"name": "distributed-rate-limiter",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "npx ts-node src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"burst-test": "npx ts-node src/test/burstTest.ts",
"concurrency-test": "npx ts-node src/test/concurrencyTest.ts"
},
"dependencies": {
"express": "5.2.1",
"ioredis": "6.0.0"
},
"devDependencies": {
"@types/express": "5.0.6",
"@types/node": "22.15.0",
"ts-node": "10.9.2",
"typescript": "5.9.3"
}
}
What does this file do?
The dependencies section lists the two packages the server needs at runtime: express (the web framework) and ioredis (the Redis client you will use in a later step).
The devDependencies section lists tools only needed during development: the TypeScript compiler, ts-node (which runs TypeScript files directly), and type definitions for Express and Node.js.
The scripts section includes shortcut commands. Some reference test files you will create in later steps.
- Save package.json.
- Install all the dependencies listed in package.json by running this command in your terminal:
npm install
You should see npm download and install all six packages. A node_modules folder and a package-lock.json file now appear in your project directory.
Seeing permission errors or missing packages?
- Make sure your terminal is inside the distributed-rate-limiter folder. Run pwd to check.
- ioredis 6.0.0 requires Node.js 20 or higher. Run node --version to confirm your version.
Still stuck? Help me troubleshoot npm install errors in my distributed-rate-limiter project.
Next, the TypeScript compiler needs to know how to process your code. A tsconfig.json file controls compiler settings like which JavaScript version to target and where to output compiled files.
- Create a new file called tsconfig.json in the root of your distributed-rate-limiter folder.
- Paste the following configuration into tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"rootDir": "./src",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": true
},
"include": ["src"]
}
What do these settings do?
- target: "ES2022" tells TypeScript to compile to modern JavaScript that supports features like top-level await.
- strict: true enables all strict type-checking options, catching more bugs at compile time.
- rootDir and outDir keep your source (./src) and compiled output (./dist) separated.
- Save tsconfig.json.
- Confirm your project files are in place by running:
ls
You should see node_modules, package-lock.json, package.json, and tsconfig.json in the output.
Create the Express app with two routes
Your project needs a src directory for all TypeScript source files. The entry point, src/index.ts, will create the Express server and define the routes you will protect with a rate limiter later.
- Create the src directory by running this command:
mkdir src
- Create a new file called index.ts inside the src folder in your editor's Explorer sidebar.
- Paste the following code into src/index.ts:
import express, { Request, Response } from 'express';
const app = express();
const PORT = parseInt(process.env.PORT || '3000', 10); // Default to port 3000
app.use(express.json());
// Health check endpoint - returns server identity
app.get('/api/ping', (req: Request, res: Response) => {
res.json({
message: 'pong',
timestamp: new Date().toISOString(),
pid: process.pid,
});
});
// Simulates a resource-intensive API endpoint
app.get('/api/heavy', (req: Request, res: Response) => {
res.json({
message: 'Heavy computation result',
timestamp: new Date().toISOString(),
pid: process.pid,
});
});
// Start the server and log identity for multi-instance debugging
app.listen(PORT, () => {
console.log(`Server running on port ${PORT} (PID: ${process.pid})`);
});
What does this code do?
- PORT reads from an environment variable so you can run multiple server instances on different ports later. It defaults to 3000.
- GET /api/ping is a lightweight health check that returns the server's process ID. You will use this to tell which instance handled a request when you test with multiple servers.
- GET /api/heavy simulates an expensive endpoint. In a real API, this might be a database query or an AI inference call that you want to protect from overuse.
- process.pid is the operating system's process identifier. Including it in both routes and the startup log makes it easy to track which instance served each request.
- Save src/index.ts.
Before you run the server, what do you expect to see in the terminal when it starts? What about when you send a request to /api/ping?
- Start the server by running this command:
npx ts-node src/index.ts
You should see a startup message in your terminal showing the port and a process ID number.
- Open a second terminal tab or window. You need a separate terminal because the server is occupying the first one.
- Send a request to the ping endpoint by running this command:
curl http://localhost:3000/api/ping
You should see a JSON response with message: "pong", a timestamp, and the server's process ID matching the one from the startup log.
Not seeing a response from the server?
- Check that the first terminal still shows the startup message and has no error output. If it crashed, look for a typo in src/index.ts.
- Make sure your curl command is running in a different terminal window or tab. The server process blocks the terminal it started in.
- If you see a port-in-use error, another process is already listening on port 3000. Stop it or start your server on a different port with PORT=3001 npx ts-node src/index.ts.
Help me troubleshoot my Express TypeScript server that is not responding on localhost:3000.
✔️ Awesome, I've got everything!
Great. Make sure you have saved all three files: package.json, tsconfig.json, and src/index.ts.
ⓧ I'd like to double check the full code
Here is the complete package.json:
{
"name": "distributed-rate-limiter",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "npx ts-node src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"burst-test": "npx ts-node src/test/burstTest.ts",
"concurrency-test": "npx ts-node src/test/concurrencyTest.ts"
},
"dependencies": {
"express": "5.2.1",
"ioredis": "6.0.0"
},
"devDependencies": {
"@types/express": "5.0.6",
"@types/node": "22.15.0",
"ts-node": "10.9.2",
"typescript": "5.9.3"
}
}
Here is the complete tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"rootDir": "./src",
"outDir": "./dist",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"declaration": true
},
"include": ["src"]
}
Here is the complete src/index.ts:
import express, { Request, Response } from 'express';
const app = express();
const PORT = parseInt(process.env.PORT || '3000', 10);
app.use(express.json());
app.get('/api/ping', (req: Request, res: Response) => {
res.json({
message: 'pong',
timestamp: new Date().toISOString(),
pid: process.pid,
});
});
app.get('/api/heavy', (req: Request, res: Response) => {
res.json({
message: 'Heavy computation result',
timestamp: new Date().toISOString(),
pid: process.pid,
});
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT} (PID: ${process.pid})`);
});
Your API server is live and responding. Next up, you will build a rate limiter that sits in front of these routes and decides which requests get through.
Build a Fixed-Window Rate Limiter
Your Express API is live and responding to requests. Right now, there is no limit on how many requests a single client can send. Someone could hammer /api/ping thousands of times per second and overwhelm your server.
The simplest solution is a fixed-window counter. It divides time into equal buckets (like 10-second intervals) and counts each client's requests per bucket. Once a client hits the limit, every additional request in that window gets a 429 Too Many Requests response. In this step, you'll build this algorithm as Express middleware.
In this step, get ready to:
- Implement a fixed-window rate limiter as Express middleware.
- Wire the limiter into your Express app and verify it blocks requests after the limit.
Implement the fixed-window middleware
The fixed-window algorithm works like a bouncer with a clicker at a nightclub entrance. Every 10 seconds, the bouncer resets the clicker to zero. Each person who walks in gets a click. Once the clicker hits the max, nobody else gets in until the next reset.
- Create the src/middleware directory by running this command:
mkdir src/middleware
- Confirm the directory exists by running:
ls src
You should see index.ts and middleware listed in the output.
- Create a new file called fixedWindowLimiter.ts inside src/middleware/.
- Add the imports, type definitions, and in-memory store by pasting this code:
import { Request, Response, NextFunction } from 'express';
// Shape of each client's rate-limit tracking data
interface WindowEntry {
count: number;
windowStart: number;
}
// In-memory store: one entry per client IP
const store = new Map<string, WindowEntry>();
// Configuration the factory function accepts
interface FixedWindowOptions {
windowSize: number;
limit: number;
}
What does this code do?
WindowEntry defines the shape of each client's tracking data: how many requests they've made (count) and when the current time window started (windowStart).
The store Map holds one entry per client IP address. This is in-memory, meaning each server instance has its own independent counter. That limitation becomes important in Step 3.
- Save fixedWindowLimiter.ts.
The types and store are in place. The next chunk adds the factory function that uses them to create the actual middleware.
- Add the factory function below the code you just pasted in fixedWindowLimiter.ts:
export function createFixedWindowLimiter(options: FixedWindowOptions) {
const { windowSize, limit } = options;
const windowMs = windowSize * 1000;
return (req: Request, res: Response, next: NextFunction): void => {
const key = req.ip || 'unknown';
const now = Date.now();
// Floor timestamp to nearest window boundary
const windowStart = Math.floor(now / windowMs) * windowMs;
const entry = store.get(key);
// First request in this window: start a fresh counter
if (!entry || entry.windowStart !== windowStart) {
store.set(key, { count: 1, windowStart });
next();
return;
}
// Over the limit: reject with 429
if (entry.count >= limit) {
res.status(429).json({
error: 'Too Many Requests',
retryAfter: Math.ceil((windowStart + windowMs - now) / 1000),
});
return;
}
// Under the limit: increment and allow
entry.count++;
next();
};
}
What does this code do?
- createFixedWindowLimiter is a factory function. You call it once with your configuration (windowSize in seconds, limit as max requests), and it returns a middleware function that Express runs on every request.
- Math.floor(now / windowMs) * windowMs is the key line. It "floors" the current timestamp to the nearest window boundary. For a 10-second window, timestamps at 10001ms and 19999ms both floor to 10000ms, putting them in the same bucket.
- When a client's count hits the limit, the middleware responds with a 429 status and a retryAfter field telling the client how many seconds until the window resets.
- Save fixedWindowLimiter.ts.
- Confirm the file was created by running:
ls src/middleware
You should see fixedWindowLimiter.ts in the output.
Don't see the file?
- Make sure you created the file inside src/middleware/, not in the project root or directly inside src/.
- Check that the filename is exactly fixedWindowLimiter.ts (camelCase, .ts extension).
Help me find my fixedWindowLimiter.ts file.
✔️ Awesome, I've got everything!
Great. Make sure you've saved fixedWindowLimiter.ts before moving on.
ⓧ I'd like to double check the full code
Scroll up and confirm your fixedWindowLimiter.ts file contains both chunks in order:
- Chunk 1: The import statement, WindowEntry interface, store Map, and FixedWindowOptions interface.
- Chunk 2: The createFixedWindowLimiter factory function with the window boundary logic, the 429 rejection, and the increment path.
Wire the limiter into the Express app and test
The middleware exists, but it is not connected to your Express app yet. You need to import it and register it with app.use() so it runs before every route.
- Switch back to src/index.ts in your editor.
- Find this import line at the top of the file:
import express, { Request, Response } from 'express';
- Add this import on the very next line:
import { createFixedWindowLimiter } from './middleware/fixedWindowLimiter';
- Next, find this line further down:
app.use(express.json());
- Add these two lines directly below it:
// Create rate limiter: 10 requests per 10-second window
const limiter = createFixedWindowLimiter({ windowSize: 10, limit: 10 });
app.use(limiter);
Why windowSize 10 and limit 10?
A 10-second window with a limit of 10 requests makes testing fast. You can hit the limit in seconds instead of waiting a full minute. In production, you would use longer windows (60 seconds or more) with higher limits.
Placing app.use(limiter) before the route definitions means every incoming request passes through the limiter first. If the client is over the limit, the middleware responds with 429 and the route handler never runs.
- Save src/index.ts.
✔️ Awesome, I've got everything!
Great. Double check you've saved the file before restarting the server.
ⓧ I'd like to double check the full code
Here is the complete src/index.ts with the limiter wired in:
import express, { Request, Response } from 'express';
import { createFixedWindowLimiter } from './middleware/fixedWindowLimiter';
const app = express();
const PORT = parseInt(process.env.PORT || '3000', 10);
app.use(express.json());
const limiter = createFixedWindowLimiter({ windowSize: 10, limit: 10 });
app.use(limiter);
app.get('/api/ping', (req: Request, res: Response) => {
res.json({
message: 'pong',
timestamp: new Date().toISOString(),
pid: process.pid,
});
});
app.get('/api/heavy', (req: Request, res: Response) => {
res.json({
message: 'Heavy computation result',
timestamp: new Date().toISOString(),
pid: process.pid,
});
});
app.listen(PORT, () => {
console.log(`Server running on port ${PORT} (PID: ${process.pid})`);
});
- In your terminal, stop the running server by pressing Ctrl+C.
- Restart the server with the limiter active by running:
npx ts-node src/index.ts
You should see the same Server running on port 3000 message as before.
Time to test. You're going to send 12 rapid requests to an endpoint with a limit of 10. Before you run the test, what do you expect to happen? How many requests should succeed, and what should happen to the rest?
- Open a second terminal tab or window (keep the server running in the first one).
- Send 12 rapid requests to the ping endpoint by running this command:
for i in $(seq 1 12); do curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/api/ping; done
What does this command do?
- seq 1 12 generates numbers 1 through 12, so the loop runs 12 times.
- curl -s -o /dev/null silences the response body so you only see status codes.
- -w "%{http_code}\n" prints just the HTTP status code for each request on its own line.
The first 10 lines show 200, and the last 2 lines show 429. Your rate limiter is working. Requests 11 and 12 hit the wall because the 10-second window already had 10 requests.
All 12 requests returning 200?
- Make sure app.use(limiter) appears before your route definitions in src/index.ts. If the limiter is registered after the routes, Express serves the routes without ever hitting the middleware.
- Confirm you restarted the server after saving your changes. Edits to .ts files do not take effect until the process restarts.
- Check that your createFixedWindowLimiter call uses limit: 10, not a higher number.
Help me debug why my rate limiter isn't blocking requests.
What is a 429 status code?
HTTP 429 Too Many Requests is the standard status code for rate limiting. It tells the client they have sent too many requests in a given time period. Well-behaved API clients check for 429 responses and back off automatically.
Your fixed-window rate limiter is doing its job on a single server. But this algorithm has two critical flaws that interviewers love to probe. In the next step, you'll break your limiter in two specific ways and see the failures firsthand.
Break It with Boundary Bursts and Multiple Instances
Your fixed-window limiter passed the basic test. It correctly blocks after 10 requests per window. But passing a single-instance smoke test is very different from surviving production traffic.
The fixed-window algorithm has two critical flaws that system design interviewers love to probe. In this step, you will break your own limiter in both ways and observe the failures firsthand.
In this step, get ready to:
- Trigger the boundary burst flaw that allows 2x the intended rate.
- Demonstrate multi-instance counter isolation failure.
Trigger the boundary burst
You need a test script that hammers your API with rapid requests so you can see exactly when the limiter kicks in. You will create this inside a new src/test/ directory.
- Open a new terminal window. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar, type Terminal, and press Enter.
- Navigate to your distributed-rate-limiter project folder and create the test directory by running these commands:
cd ~/Desktop/distributed-rate-limiter
mkdir -p src/test
- Confirm the directory exists by running:
ls src/
You should see index.ts, middleware, and a new test directory in the output.
- Create a new file called burstTest.ts inside src/test/ in your code editor.
- Add the request helper function by pasting the following code into burstTest.ts:
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000';
const ENDPOINT = '/api/ping';
async function sendRequest(index: number): Promise<{ index: number; status: number; remaining: string | null }> {
const response = await fetch(`${BASE_URL}${ENDPOINT}`);
return {
index,
status: response.status,
remaining: response.headers.get('ratelimit-remaining'),
};
}
What does this code do?
- BASE_URL and ENDPOINT define the target URL. You can override the base URL with an environment variable later when testing different ports.
- sendRequest fires a single fetch call to the API and returns the HTTP status code along with any rate-limit headers the server sends back.
- Add the main test runner function below the sendRequest function:
async function runBurstTest() {
console.log(`\nBurst test: sending rapid requests to ${BASE_URL}${ENDPOINT}\n`);
const results: string[] = [];
for (let i = 1; i <= 15; i++) {
const result = await sendRequest(i);
const label = result.status === 200 ? 'ALLOWED' : 'BLOCKED';
const line = `Request ${String(i).padStart(2)}: ${label} (${result.status}) | Remaining: ${result.remaining ?? 'N/A'}`;
console.log(line);
results.push(line);
}
const allowed = results.filter(r => r.includes('ALLOWED')).length;
const blocked = results.filter(r => r.includes('BLOCKED')).length;
console.log(`\nSummary: ${allowed} allowed, ${blocked} blocked\n`);
}
runBurstTest().catch(console.error);
What does this code do?
- runBurstTest fires 15 requests one after another in a tight loop. With a limit of 10, the first 10 should succeed and the remaining 5 should be blocked.
- Each request is logged as ALLOWED or BLOCKED so you can see exactly where the limiter cuts off. The summary at the bottom tallies the totals.
- Save burstTest.ts.
✔️ Awesome, I've got everything!
Double check you have saved src/test/burstTest.ts.
ⓧ I'd like to double check the full code
Here is the complete src/test/burstTest.ts file. Make sure your code matches exactly:
const BASE_URL = process.env.BASE_URL || 'http://localhost:3000';
const ENDPOINT = '/api/ping';
async function sendRequest(index: number): Promise<{ index: number; status: number; remaining: string | null }> {
const response = await fetch(`${BASE_URL}${ENDPOINT}`);
return {
index,
status: response.status,
remaining: response.headers.get('ratelimit-remaining'),
};
}
async function runBurstTest() {
console.log(`\nBurst test: sending rapid requests to ${BASE_URL}${ENDPOINT}\n`);
const results: string[] = [];
for (let i = 1; i <= 15; i++) {
const result = await sendRequest(i);
const label = result.status === 200 ? 'ALLOWED' : 'BLOCKED';
const line = `Request ${String(i).padStart(2)}: ${label} (${result.status}) | Remaining: ${result.remaining ?? 'N/A'}`;
console.log(line);
results.push(line);
}
const allowed = results.filter(r => r.includes('ALLOWED')).length;
const blocked = results.filter(r => r.includes('BLOCKED')).length;
console.log(`\nSummary: ${allowed} allowed, ${blocked} blocked\n`);
}
runBurstTest().catch(console.error);
Before you run the test, predict: how many of the 15 requests do you think will succeed, and how many will be blocked?
- Make sure your server is still running on port 3000 in its terminal. If it stopped, restart it by running npx ts-node src/index.ts from the distributed-rate-limiter folder.
- Run the burst test from your second terminal by running:
npx ts-node src/test/burstTest.ts
You should see 10 requests marked ALLOWED followed by 5 marked BLOCKED. The limiter appears to work perfectly.
Seeing all 15 requests ALLOWED?
If more than 10 requests succeeded, you may have accidentally run the test right on a window boundary. The 10-second window reset, giving the later requests a fresh counter. Wait a couple of seconds and run the test again.
If you see a connection refused error, make sure the server is still running in its terminal on port 3000.
Help me debug my burst test results.
The limiter caught the burst. But did you notice that troubleshooting hint? If your test happened to straddle a window boundary, more than 10 requests could have slipped through. That is not a coincidence. That is the first critical flaw.
What is the boundary burst problem?
The fixed-window algorithm divides time into discrete buckets. Each bucket starts with a fresh counter. A clever client can exploit this by sending limit requests at the very end of one window and another limit requests at the very start of the next window.
With your settings (10 requests per 10 seconds), a client could send 10 requests at second 9.9 and 10 more at second 10.1. That is 20 requests in less than a second, with a limit of 10 per 10 seconds. The client achieved 2x the intended rate.
- Prove the boundary burst by sending 10 rapid requests to fill the current window. Run this command:
for i in $(seq 1 10); do curl -s -o /dev/null -w "%{http_code} " http://localhost:3000/api/ping; done && echo ""
You should see ten 200 status codes. The window is now full.
- Wait about 10 seconds for the window to reset, then immediately fire another 10 requests by running the same command:
for i in $(seq 1 10); do curl -s -o /dev/null -w "%{http_code} " http://localhost:3000/api/ping; done && echo ""
Another ten 200 status codes. You just sent 20 successful requests against a limit of 10 by timing them around the window boundary. In production, a malicious client could automate this timing and sustain 2x your intended rate indefinitely.
Demonstrate multi-instance failure
The boundary burst is flaw number one. Flaw number two shows up when you scale horizontally. In production, your API runs behind a load balancer that distributes traffic across multiple server instances.
Each instance of your app has its own in-memory Map store. That means each instance tracks its own counter, completely unaware of requests hitting the other instances.
- Open a third terminal window.
- Navigate to your distributed-rate-limiter folder and start a second server instance on port 3001 by running:
cd ~/Desktop/distributed-rate-limiter
PORT=3001 npx ts-node src/index.ts
You should see a log message confirming the second instance started with a different PID: Server running on port 3001 (PID: ...).
Seeing EADDRINUSE?
This error means port 3001 is already in use. Make sure you included PORT=3001 before the command. Without it, the second instance tries to start on port 3000, which is already taken by your first server.
Help me start a second server instance.
- Switch back to your second terminal (the one you used for the burst test).
- Wait about 10 seconds for any previous window to expire, then send 10 requests to port 3000 by running:
for i in $(seq 1 10); do curl -s -o /dev/null -w "%{http_code} " http://localhost:3000/api/ping; done && echo ""
You should see ten 200 status codes. Port 3000 accepted all 10 requests and its counter is now full.
Before you send requests to port 3001, predict: you already used up all 10 of your allowed requests. Will the limiter on port 3001 block you?
- Send 10 requests to port 3001 by running:
for i in $(seq 1 10); do curl -s -o /dev/null -w "%{http_code} " http://localhost:3001/api/ping; done && echo ""
Another ten 200 status codes. Every single request succeeded.
Why did port 3001 allow all 10?
Each server instance has its own in-memory Map store. Port 3000's counter and port 3001's counter are completely independent. The client made 20 requests total against a limit of 10, and every request succeeded.
In a real deployment behind a load balancer, traffic round-robins across instances. Every instance sees only a fraction of the true request count. A client sending 100 requests against 10 instances would get 100 through, not 10.
You have now proven that the fixed-window limiter is broken in two independent ways. The boundary burst allows 2x the rate at window edges. Multi-instance deployment lets clients bypass the limit entirely because no instance sees the full picture.
You have two confirmed failures: boundary bursts that allow 2x the intended rate, and per-instance counters that crumble under horizontal scaling. Next up, you will fix both problems at once by replacing the in-memory store with Redis and swapping the algorithm to a sliding window counter.
Fix It with a Redis Sliding Window Counter
In Step 3, you witnessed the fixed-window limiter break in two independent ways. Each server tracked its own counter in memory, letting a client bypass the limit by hitting a second instance. Requests straddling a window boundary achieved double the intended rate.
Fixing both failures requires two changes. A shared data store (Redis) gives every server instance a single source of truth for request counts. A sliding window counter algorithm eliminates the boundary burst by blending two adjacent windows into a weighted estimate. In this step, you will spin up Redis, build the sliding window counter with an atomic Lua script, and wire it into your Express app.
In this step, get ready to:
- Start a Redis container and create the client module.
- Implement the sliding window counter algorithm as an atomic Lua script.
- Replace the fixed-window limiter and verify both fixes hold.
Start Redis and create the client module
Redis is an in-memory data store that all your server instances can read from and write to. By moving your request counters into Redis, every instance sees the same count.
- Make sure Docker is running. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Docker and press Enter to open Docker Desktop.
- Start a Redis container by running this command in your terminal:
docker run -d --name redis-limiter -p 6379:6379 redis:8
What does this command do?
- -d runs the container in the background so your terminal stays free.
- --name redis-limiter gives the container a memorable name you can reference later.
- -p 6379:6379 maps Redis's default port from the container to your machine.
- redis:8 pulls the latest Redis 8 image from Docker Hub.
- Verify Redis is running by pinging it:
docker exec redis-limiter redis-cli PING
You should see PONG printed in your terminal. That confirms the Redis container is healthy and accepting connections.
Not seeing PONG?
- Run docker ps to check if the container is listed as running. If it is not, run docker start redis-limiter.
- If you see a name conflict error, a container with the same name already exists. Run docker rm -f redis-limiter first, then re-run the docker run command.
- Make sure Docker Desktop is running before you try any Docker commands.
Help me debug my Redis container
Now create the module that your application will use to talk to Redis. The ioredis package is already in your package.json from the project setup.
- Create a new file called src/redis.ts and add the following code:
import Redis from 'ioredis';
// Default to local Redis, overridable via environment variable
const REDIS_URL = process.env.REDIS_URL || 'redis://localhost:6379';
// Create the client with retry logic for resilience
export const redisClient = new Redis(REDIS_URL, {
maxRetriesPerRequest: 3,
retryStrategy(times: number) {
// Stop retrying after 3 failed attempts
if (times > 3) return null;
// Exponential backoff capped at 2 seconds
return Math.min(times * 200, 2000);
},
});
// Log connection events for debugging
redisClient.on('connect', () => {
console.log('Redis connected');
});
redisClient.on('error', (err: Error) => {
console.error('Redis error:', err.message);
});
What does this code do?
- REDIS_URL defaults to redis://localhost:6379 but can be overridden with an environment variable for production deployments.
- retryStrategy controls what happens when the connection drops. It retries up to 3 times with increasing delays (200ms, 400ms, 600ms), then gives up and returns null.
- The connect and error event listeners print status messages so you can confirm the connection is alive when the server starts.
- Save src/redis.ts.
Implement the sliding window counter with a Lua script
The sliding window counter uses two fixed-window counters (current and previous) and a weighted average to approximate a true sliding window. This gives O(1) memory per client with approximately 0.003% error.
The entire check-and-increment must happen atomically. If one instance reads the count and another increments it between the read and the write, the limiter leaks requests. A Lua script solves this because Redis executes Lua scripts atomically. No other commands can interleave during the script.
- Create a new file called src/middleware/slidingWindowLimiter.ts and add the imports and options interface:
import { Request, Response, NextFunction } from 'express';
import Redis from 'ioredis';
// Configuration for the sliding window limiter
interface SlidingWindowOptions {
windowSize: number; // Window duration in seconds
limit: number; // Max requests per window
keyPrefix: string; // Redis key prefix
redis: Redis; // Shared Redis client
failOpen: boolean; // Allow requests if Redis is down?
}
What is failOpen?
When failOpen is true, requests pass through if Redis is unreachable. When false, the server rejects all requests with a 503 during a Redis outage. You will test both behaviors in Step 5.
- Add the Lua script constant below the interface:
const LUA_SCRIPT = `
local current_key = KEYS[1]
local previous_key = KEYS[2]
local limit = tonumber(ARGV[1])
local window_size = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local current_window_start = tonumber(ARGV[4])
local current_count = tonumber(redis.call('GET', current_key) or '0') or 0
local previous_count = tonumber(redis.call('GET', previous_key) or '0') or 0
local elapsed = now - current_window_start
local weight = math.max(0, 1 - (elapsed / window_size))
local estimated_count = previous_count * weight + current_count
if estimated_count + 1 > limit then
local reset_at = current_window_start + window_size
return {0, math.ceil(estimated_count), reset_at}
end
redis.call('INCR', current_key)
redis.call('EXPIRE', current_key, window_size * 2)
return {1, math.ceil(estimated_count + 1), current_window_start + window_size}
`;
How does the sliding window algorithm work?
The script reads two counters from Redis: one for the current time window and one for the previous window. It computes how much of the previous window overlaps with the current sliding window.
The formula is: estimated_count = previous_count * weight + current_count. The weight shrinks from 1 toward 0 as time moves further into the current window. If the estimate exceeds the limit, the script returns 0 (rejected). Otherwise it atomically increments the current counter and sets a TTL of 2x the window size for cleanup.
Why two windows instead of one?
A single fixed window resets to zero at the boundary, allowing a client to fire a full batch on each side of the reset. Blending the previous window's count into the estimate smooths the transition, eliminating the 2x burst you observed in Step 3.
- Add the factory function below the Lua script. Start with the function signature, key computation, and the redis.eval call:
export function createSlidingWindowLimiter(options: SlidingWindowOptions) {
const { windowSize, limit, keyPrefix, redis, failOpen } = options;
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
const identifier = req.ip || 'unknown';
const now = Math.floor(Date.now() / 1000);
const currentWindowStart = Math.floor(now / windowSize) * windowSize;
const previousWindowStart = currentWindowStart - windowSize;
// Hash tags {identifier} ensure both keys land on the same Redis Cluster slot
const currentKey = `${keyPrefix}:{${identifier}}:${currentWindowStart}`;
const previousKey = `${keyPrefix}:{${identifier}}:${previousWindowStart}`;
try {
// Execute the Lua script atomically inside Redis
const result = await redis.eval(
LUA_SCRIPT,
2,
currentKey,
previousKey,
limit,
windowSize,
now,
currentWindowStart
) as [number, number, number];
What are Redis hash tags?
The key pattern rl:{ip}:timestamp uses curly braces around the client identifier. In a Redis Cluster deployment, keys with the same hash tag are guaranteed to live on the same shard. This is required because Lua scripts that touch multiple keys only work when all keys are on the same node.
- Continue the function by adding the response handling and the 429 rejection logic directly below:
const [allowed, currentCount, resetAt] = result;
// Set rate-limit headers on every response
res.set({
'RateLimit-Limit': String(limit),
'RateLimit-Remaining': String(Math.max(0, limit - currentCount)),
'RateLimit-Reset': String(resetAt),
});
if (!allowed) {
const retryAfter = Math.max(1, resetAt - now);
res.set('Retry-After', String(retryAfter));
res.status(429).json({
error: 'Too Many Requests',
limit,
current: currentCount,
retryAfter,
});
return;
}
- Finish the function by adding the success path, the catch block for Redis failures, and the closing braces:
next();
} catch (err) {
console.error('Rate limiter error:', (err as Error).message);
// Fail-open: let requests through if Redis is down
// Fail-closed: reject all requests if Redis is down
if (failOpen) {
next();
} else {
res.status(503).json({ error: 'Service temporarily unavailable' });
}
}
};
}
What does the catch block do?
If Redis is unreachable, the redis.eval call throws an error. The catch block checks the failOpen flag. When true, the middleware calls next() and lets the request through. When false, it responds with 503 Service Unavailable.
- Save src/middleware/slidingWindowLimiter.ts.
✔️ Awesome, I've got everything!
Great. Double check you have saved the file before moving on.
ⓧ I'd like to double check the full code
Your src/middleware/slidingWindowLimiter.ts is built from the chunks above. Scroll up and confirm each section is present in this order:
- Imports (express and ioredis) and the SlidingWindowOptions interface.
- The LUA_SCRIPT template literal with the weighted sliding window logic.
- The createSlidingWindowLimiter factory function, starting with key computation and redis.eval.
- Response header setting, the 429 rejection block, and the next() call.
- The catch block with fail-open/fail-closed branching, and the closing braces.
Switch to the sliding window limiter and test
The limiter and Redis client are ready. Now swap the imports and configuration in src/index.ts so the app uses the new middleware.
- In src/index.ts, find the import lines at the top of the file:
import express, { Request, Response } from 'express';
import { createFixedWindowLimiter } from './middleware/fixedWindowLimiter';
- Replace those two import lines with these three:
import express, { Request, Response } from 'express';
import { createSlidingWindowLimiter } from './middleware/slidingWindowLimiter';
import { redisClient } from './redis';
- Next, find the limiter creation line:
const limiter = createFixedWindowLimiter({ windowSize: 10, limit: 10 });
- Replace it with the sliding window limiter configuration:
const limiter = createSlidingWindowLimiter({
windowSize: 10,
limit: 10,
keyPrefix: 'rl',
redis: redisClient,
failOpen: true,
});
What changed?
- The limiter now takes a redis client so all instances share counters through Redis instead of local memory.
- keyPrefix: 'rl' namespaces the Redis keys so they do not collide with other data in the same Redis instance.
- failOpen: true means requests pass through if Redis goes down, preserving availability.
- Save src/index.ts.
- Stop any servers that are still running from Step 3 by pressing Ctrl+C in each terminal.
- Start the first server instance by running:
npx ts-node src/index.ts
You should see two log lines: Redis connected and Server running on port 3000. The first line confirms your Redis client module is working.
Seeing a Redis connection error?
- Run docker ps in a separate terminal to confirm the redis-limiter container is listed and running.
- If the container stopped, run docker start redis-limiter and restart your server.
- Check that no other process is using port 6379 on your machine.
Help me fix my Redis connection error
- Open a second terminal and start a second server instance on port 3001:
PORT=3001 npx ts-node src/index.ts
You should see Redis connected and Server running on port 3001 in this terminal too. Both instances are now sharing the same Redis counters.
Time to prove the multi-instance fix. Before you run the test, think about what you expect to happen. In Step 3, each instance allowed 10 requests independently (20 total). What do you think will happen now that both instances share Redis?
- Open a third terminal and send 6 requests to port 3000:
for i in $(seq 1 6); do curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/api/ping; done
- Then send 6 requests to port 3001:
for i in $(seq 1 6); do curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3001/api/ping; done
The first 10 requests across both ports return 200. Requests 11 and 12 return 429 regardless of which port you hit. The shared Redis counter enforces the global limit.
Now test the boundary burst fix. In Step 3, the burst test achieved 20 successful requests across a window boundary. Do you think the sliding window counter will still allow that?
- Wait at least 10 seconds for the current window to reset, then run the burst test:
npx ts-node src/test/burstTest.ts
You should see only 10 requests allowed and 5 blocked. The sliding window's weighted estimate carries the previous window's count forward, preventing the 2x burst that the fixed window allowed.
Why does the burst test pass now?
When the window boundary crosses, the fixed-window limiter reset its counter to zero and allowed a fresh batch. The sliding window counter still sees the previous window's requests through the weighted average. If you sent 10 requests right before the boundary, the estimate stays close to 10 even after the boundary passes, blocking the second burst.
Still seeing more than 10 allowed?
- Make sure you waited at least 10 seconds before running the burst test so the previous window's requests expired.
- Confirm src/index.ts imports createSlidingWindowLimiter and not createFixedWindowLimiter.
- Verify that src/redis.ts logged Redis connected when your server started. If not, the limiter may be falling through to fail-open mode without actually checking Redis.
Help me debug my sliding window limiter
✔️ Awesome, I've got everything!
Great. Double check you have saved all files before moving on.
ⓧ I'd like to double check the full code
Here is the full src/index.ts after the changes:
import express, { Request, Response } from 'express';
import { createSlidingWindowLimiter } from './middleware/slidingWindowLimiter';
import { redisClient } from './redis';
const app = express();
const PORT = parseInt(process.env.PORT || '3000', 10);
app.use(express.json());
const limiter = createSlidingWindowLimiter({
windowSize: 10,
limit: 10,
keyPrefix: 'rl',
redis: redisClient,
failOpen: true,
});
app.use(limiter);
The route handlers and app.listen call below this section are unchanged from Step 1.
For src/redis.ts and src/middleware/slidingWindowLimiter.ts, scroll up to the chunks you pasted earlier and confirm each section is present in order.
Both flaws are fixed. Redis gives every instance a shared counter, and the sliding window algorithm smooths the boundary so clients can never burst past the limit. Next up, you will harden this middleware for production with standard rate-limit headers, a concurrent load test, and graceful failure handling when Redis goes down.
Harden for Production: Headers, Concurrency Testing, and Failure Modes
In Step 4, you built a Redis-backed sliding window counter that enforces rate limits across multiple server instances. The algorithm works, but a rate limiter that silently blocks requests gives API consumers no way to self-throttle.
In this step, you'll verify the response headers your limiter sends, run a concurrent load test that hammers both server instances at once, and test what happens when Redis goes down.
In this step, get ready to:
- Verify rate-limit response headers on allowed and rejected requests.
- Run a concurrent load test across both server instances.
- Test fail-open and fail-closed behavior under Redis failure.
Verify the rate-limit response headers
Your sliding window limiter already sets four headers on its responses: three RateLimit- headers on every response and a Retry-After header on 429 rejections. These headers follow an IETF draft standard so API consumers can programmatically self-throttle.
Let's confirm they appear in a live response. If you ran tests recently, wait about 10 seconds for the current rate-limit window to reset.
- Send a request that includes response headers by running this command in a spare terminal:
curl -i http://localhost:3000/api/ping
You should see a 200 response with RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset headers alongside the JSON body.
What do these headers mean?
- RateLimit-Limit: the maximum number of requests the client is allowed in the current window (10 in your case).
- RateLimit-Remaining: how many requests the client has left before hitting the limit.
- RateLimit-Reset: a Unix timestamp indicating when the current window ends and the quota resets.
- Retry-After: only sent on 429 responses, telling the client how many seconds to wait before retrying.
Now let's exhaust the limit and see the 429 response with the Retry-After header.
- Burn through the remaining quota and inspect the rejected response by running these commands:
for i in $(seq 1 10); do curl -s -o /dev/null http://localhost:3000/api/ping; done
curl -i http://localhost:3000/api/ping
The last request returns 429 Too Many Requests with RateLimit-Remaining: 0 and a Retry-After header telling the client exactly how many seconds to wait.
Not seeing the headers?
Make sure the server on port 3000 is still running from Step 4. If it stopped, restart it with npx ts-node src/index.ts from the distributed-rate-limiter folder.
If you see all 200s instead of a 429, you may not have waited long enough for the previous test window to expire. Wait 10 seconds and try again.
Help me debug missing rate-limit headers in my Express response.
Run a concurrent load test across both instances
Sending requests one at a time proved the limiter works under polite usage. Production traffic is not polite. Multiple clients hit multiple instances at the same instant, and if the Lua script's atomicity has any gaps, you'll see more than 10 requests sneak through.
You'll build a test script that fires 20 requests in parallel across both server instances and tallies the results.
- Make sure both server instances are still running from Step 4. You should have one terminal running the server on port 3000 and another on port 3001.
Second instance not running?
If the port 3001 instance stopped, open a new terminal, navigate to your distributed-rate-limiter folder, and start it by running:
PORT=3001 npx ts-node src/index.ts
- Create the concurrency test file by adding constants, the result interface, and a helper function. Create src/test/concurrencyTest.ts and paste the following code:
const PORTS = [3000, 3001];
const TOTAL_REQUESTS = 20;
const ENDPOINT = '/api/ping';
interface RequestResult {
port: number;
status: number;
remaining: string | null;
}
async function sendRequest(port: number): Promise<RequestResult> {
const response = await fetch(`http://localhost:${port}${ENDPOINT}`);
return {
port,
status: response.status,
remaining: response.headers.get('ratelimit-remaining'),
};
}
What does this code do?
- PORTS lists both server instances so the test can distribute requests across them.
- TOTAL_REQUESTS is set to 20, which is double the configured limit of 10. Exactly half should be allowed and half blocked.
- sendRequest fires a single request to a specific port and captures the status code and the ratelimit-remaining header from the response.
- Add the main test function below sendRequest. This function builds all 20 promises and fires them at once with Promise.all:
async function runConcurrencyTest() {
console.log(`\nConcurrency test: sending ${TOTAL_REQUESTS} parallel requests across ports ${PORTS.join(', ')}\n`);
const promises: Promise<RequestResult>[] = [];
for (let i = 0; i < TOTAL_REQUESTS; i++) {
const port = PORTS[i % PORTS.length];
promises.push(sendRequest(port));
}
const results = await Promise.all(promises);
results.forEach((result, i) => {
const label = result.status === 200 ? 'ALLOWED' : result.status === 429 ? 'BLOCKED' : `ERROR(${result.status})`;
console.log(
`Request ${String(i + 1).padStart(2)}: ${label} | Port: ${result.port} | Remaining: ${result.remaining ?? 'N/A'}`
);
});
What does this code do?
- The for loop alternates ports using modulo (i % PORTS.length), distributing requests evenly across both instances.
- Promise.all fires all 20 requests concurrently, simulating a realistic burst of parallel traffic.
- The forEach logs each request's result with its port and remaining quota so you can see exactly which instance handled each request.
- Still inside the runConcurrencyTest function, add the tallying and reporting logic below the forEach block. Then close the function and add the invocation:
const allowed = results.filter(r => r.status === 200).length;
const blocked = results.filter(r => r.status === 429).length;
const errors = results.filter(r => r.status !== 200 && r.status !== 429).length;
console.log(`\nSummary:`);
console.log(` Allowed: ${allowed} (expected: 10)`);
console.log(` Blocked: ${blocked} (expected: 10)`);
if (errors > 0) console.log(` Errors: ${errors}`);
if (allowed === 10) {
console.log(`\n PASS: Global limit held under concurrent load across ${PORTS.length} instances.\n`);
} else {
console.log(`\n FAIL: Expected 10 allowed but got ${allowed}. Possible atomicity issue.\n`);
}
}
runConcurrencyTest().catch(console.error);
What does this code do?
- The three filter calls tally how many requests got 200, 429, or an unexpected status code.
- The PASS/FAIL check verifies that exactly 10 requests were allowed. If the Lua script were not atomic, race conditions between the read and increment would occasionally let 11 or 12 through.
- Save src/test/concurrencyTest.ts.
✔️ Awesome, I've got everything!
Make sure you've saved the file before running the test.
ⓧ I'd like to double check the full code
Your concurrencyTest.ts file should contain these sections in order:
- Three constants: PORTS set to [3000, 3001], TOTAL_REQUESTS set to 20, and ENDPOINT set to '/api/ping'.
- The RequestResult interface with port, status, and remaining fields.
- The sendRequest function that fetches from a given port and returns a RequestResult.
- The runConcurrencyTest function that builds promises, fires them with Promise.all, logs individual results, tallies allowed/blocked/errors, and prints PASS or FAIL.
- The runConcurrencyTest().catch(console.error) invocation at the bottom.
Wait about 10 seconds for any previous rate-limit window to expire before running the test.
Before you run this, think: you're sending 20 requests in parallel across two instances, both backed by the same Redis counters. Do you expect exactly 10 allowed, or could the parallel timing cause more to slip through?
- Run the concurrency test from a third terminal (not one of the server terminals) by running this command:
npx ts-node src/test/concurrencyTest.ts
You should see exactly 10 requests marked ALLOWED and 10 marked BLOCKED, with a PASS message at the bottom.
Why exactly 10 and not 11 or 12?
The Lua script executes atomically inside Redis. No other command can interleave between reading the counter and incrementing it. Without that atomicity, two requests arriving at the same instant could both read a count of 9, both decide there's room, and both increment to 10, letting 11 through.
This is the exact scenario a system design interviewer probes when they ask about race conditions in distributed rate limiters.
Seeing more than 10 allowed?
If you ran the test too quickly after a previous test, some requests from the old window may still be counted. Wait 10 seconds for the window to reset and run the test again.
Make sure both server instances are using createSlidingWindowLimiter (not the old fixed-window limiter) and that both connect to the same Redis instance on port 6379.
Help me debug my concurrency test results.
Test fail-open and fail-closed behavior under Redis failure
Your limiter depends on Redis. What happens when Redis goes down? The try/catch in your sliding window middleware already handles this with the failOpen option. Let's prove it works both ways.
- Stop the Redis container by running this command:
docker stop redis-limiter
You should see Redis error messages start appearing in your server terminal logs.
Your limiter is currently configured with failOpen: true. Before you send a request, predict: will it succeed with a 200, or fail with an error?
- Test the fail-open behavior by sending a request:
curl -i http://localhost:3000/api/ping
You get a 200 response. The request succeeded even though Redis is down. Your server terminal shows a Redis connection error, but the middleware caught it and called next() instead of blocking.
What is fail-open?
Fail-open means the system allows requests to pass when the rate limiter itself is broken. This preserves availability for your users at the cost of temporarily having no rate limiting. It's the right default for most user-facing APIs where uptime matters more than strict enforcement.
Now let's see the opposite behavior. You'll switch to fail-closed mode and confirm that a Redis outage blocks all traffic.
- Stop the server on port 3000 by pressing Ctrl+C in its terminal.
- In src/index.ts, find this line in the limiter configuration:
failOpen: true,
- Change true to false so the line reads:
failOpen: false,
- Save src/index.ts and restart the server by running:
npx ts-node src/index.ts
You'll see the server start, followed immediately by Redis error messages since the container is still stopped. Now predict: with failOpen: false, will the request succeed or fail?
- Test the fail-closed behavior by sending a request:
curl -i http://localhost:3000/api/ping
This time you get a 503 Service Unavailable response. The middleware caught the Redis error and refused the request instead of letting it through.
When should you use each strategy?
Fail-open preserves availability but risks abuse during a Redis outage. Use it for user-facing APIs where uptime matters most.
Fail-closed protects the backend but causes a total outage for all clients if Redis dies. Use it for financial or security-critical endpoints where exceeding the limit is unacceptable.
In a system design interview, name both strategies and explain which you would choose based on the system's requirements. That trade-off reasoning is what separates senior answers from textbook answers.
Time to restore everything to a working state.
- Stop the server by pressing Ctrl+C.
- In src/index.ts, change failOpen: false back to failOpen: true and save the file.
- Restart the Redis container by running:
docker start redis-limiter
- Restart your server to confirm everything connects cleanly:
npx ts-node src/index.ts
You should see Redis connected in the logs, confirming your limiter is fully operational again.
Redis not reconnecting?
Make sure the container is running by checking docker ps. You should see redis-limiter in the list. If it's not there, run docker start redis-limiter again.
Help me reconnect my Node.js server to Redis after restarting the container.
Secret mission
Compare Algorithms with a Sliding Window Log
Can you build a second rate-limiting algorithm using Redis sorted sets and measure the exact memory trade-off against your sliding window counter? In a system design interview, naming two algorithms is table stakes. Building both and articulating the trade-off from firsthand experience is what separates senior candidates.
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely on your local machine using a Docker container. There are no cloud costs or ongoing charges.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- A Docker container named redis-limiter running Redis on port 6379.
- The distributed-rate-limiter project folder on your Desktop with all source files and installed npm packages.
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with the rate limiter.
- Your redis-limiter container continues running in the background and uses minimal system resources.
- Your project files stay on your Desktop, ready to edit and run.
Pause - I'll come back to this later
Shut down running processes to free up memory and port 6379. Your project files and the Docker container stay intact so you can pick up later.
- Stop any running server instances by pressing Ctrl+C in each terminal window.
- Stop the Redis container by running:
docker stop redis-limiter
- When you are ready to come back, restart the container by running:
docker start redis-limiter
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes the Docker container and the project folder permanently.
- Stop any running server instances by pressing Ctrl+C in each terminal window.
- Remove the Redis container by running:
docker rm -f redis-limiter
- Delete the project folder. On macOS or Linux, run:
cd ~/Desktop
rm -rf distributed-rate-limiter
- On Windows, run:
cd ~\Desktop
rmdir /s /q distributed-rate-limiter
- Confirm the folder is gone by running ls (macOS/Linux) or dir (Windows). You should no longer see distributed-rate-limiter in the output.
Nice Work!
Nice Work!
Nice work! You just built a production-grade distributed rate limiter from scratch using Node.js, TypeScript, and Redis. You didn't just configure a library. You wrote the algorithms, broke them on purpose, and fixed them with the exact techniques that senior engineers defend in system design interviews.
You've learned how to:
- Build two rate-limiting algorithms from scratch. You started with a fixed-window counter, discovered its boundary burst flaw and multi-instance failure, then replaced it with a sliding window counter backed by Redis and an atomic Lua script that prevents race conditions under concurrent load.
- Harden a rate limiter for production with standard rate-limit response headers (RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, Retry-After) and validate correctness under concurrent load across multiple server instances.
- Implement and test fail-open vs fail-closed degradation strategies for Redis outages. You can now reason about the operational trade-off between availability and safety in a system design interview.
- Secret Mission: Completed an optional challenge to push your distributed systems skills further.
Ready to quiz yourself?