Build an API Rate Limiter from Scratch
Build a token bucket rate limiter in Node.js with HTTP 429 responses.
Introduction
30 Second Summary
Every time you refresh a social media feed or check a weather app, your device fires requests at an API. Without protection, a single client can flood a server with thousands of requests and bring it to its knees.
In this project, you will build an API rate limiter from scratch using Node.js and Express. You will implement the same algorithm that Stripe, GitHub, and AWS use to protect their APIs, then watch it block excess traffic in real time.
What You'll Build
Fire a flood of requests at your own API and watch it intelligently block the excess while telling well-behaved clients exactly when they can retry.
By the end of this project, you'll have:
- A running API where you send requests to multiple endpoints and see the token bucket algorithm decide which requests pass and which get blocked.
- Proper HTTP 429 responses with Retry-After and rate-limit headers that tell clients exactly when to try again.
- Per-route protection policies that enforce different limits depending on how sensitive each endpoint is. Strict for login, generous for reads.
- Secret Mission: Implement the sliding window counter algorithm as a second limiter and compare its behaviour to token bucket.
Are there any prerequisites?
You need basic programming experience in any language (Python or similar is fine). No prior JavaScript or Node.js experience is required. This project teaches you everything you need as you go.
Before We Start
Before We Start...
Before we dive in, let's lock in what you're about to build and why it matters.
You're going to build a rate limiter from scratch using the token bucket algorithm. This is the same technique Stripe, GitHub, and AWS use to protect their APIs from being overwhelmed by too many requests.
Set Up Your Node.js Project
Every API starts with a foundation. Before you can build rate-limiting logic, you need a running server that accepts requests.
In this step, you will create a project folder, install Express (a popular Node.js web framework), and spin up a minimal server that listens for incoming traffic.
In this step, get ready to:
- Create a rate-limiter project directory and initialize it with npm.
- Install Express as a dependency.
- Create a server.js file and verify the server starts.
Create your project directory and initialize npm
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) 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 rate-limiter
cd rate-limiter
- Initialize a new Node.js project by running:
npm init -y
You should see a JSON object printed in the terminal showing your new package.json with the name rate-limiter.
What does this command do?
The npm init -y command creates a package.json file, which tracks your project's name, version, and dependencies.
The -y flag accepts all defaults so you skip the interactive prompts.
Seeing 'command not found'?
This means Node.js is not on your system PATH. Reinstall Node.js from the official website and make sure the installer adds it to your PATH.
help me fix npm command not found on my system
Install Express
- Install Express by running:
npm install express
You should see output ending with a line like added X packages confirming the install succeeded.
What did this do?
This command downloaded the Express framework and saved it as a dependency in your package.json.
It also created a node_modules folder where the downloaded code lives.
Install failing with network errors?
Make sure you have an internet connection. If you are behind a corporate proxy, you may need to configure npm proxy settings.
help me fix npm install network errors
Create your server and start it
Now you need a code editor to write your server file. Let's open VS Code and point it at your project.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Visual Studio Code and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the rate-limiter folder, and click Open.
You should see package.json and node_modules listed in the Explorer sidebar on the left.
- Click on package.json in the Explorer sidebar to open it.
- Replace the entire contents of the file with the following configuration:
{
"name": "rate-limiter",
"version": "1.0.0",
"description": "API Rate Limiter from Scratch",
"main": "server.js",
"scripts": {
"start": "node server.js",
"test-flood": "node test-client.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"express": "^5.2.1"
}
}
What are these fields?
- main tells Node.js which file is the entry point for your project.
- scripts defines shortcut commands you can run with npm start or npm run test-flood.
- dependencies lists the packages your project needs. Express ^5.2.1 is already here from your install.
- Save package.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).
Now create the server file that Express will run.
- Hover over the Explorer sidebar area and click the New File icon (a page with a plus sign).
- Type server.js as the filename and press Enter.
- Paste the following code into server.js:
const express = require('express');
const app = express();
const port = 3000;
// Parse incoming JSON request bodies
app.use(express.json());
// Start the server and print confirmation
app.listen(port, () => {
console.log(`API server running at http://localhost:${port}`);
});
What does this code do?
- require('express') loads the Express framework you installed.
- express() creates a new application instance that can handle HTTP requests.
- express.json() is middleware that automatically parses JSON in incoming request bodies.
- app.listen(port, callback) starts the server on port 3000 and runs the callback once it is ready.
- Save server.js by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Open VS Code's integrated terminal by pressing Ctrl+` (backtick key, same on both macOS and Windows).
Before you run this, what do you think the terminal will print if everything is wired up correctly?
- Start your server by running:
node server.js
You should see this printed in the terminal:
API server running at http://localhost:3000
Your server is alive and listening for requests on port 3000.
Don't see the running message?
- If you see a syntax error, double-check that the backticks around the console.log template string are backticks (`), not regular quotes.
- If you see Cannot find module 'express', make sure you ran npm install express inside the rate-limiter directory. Check that node_modules exists in your Explorer sidebar.
- If port 3000 is already in use, close other running servers or restart your terminal.
help me fix my Express server not starting
- Press Ctrl+C in the terminal to stop the server. You will need the terminal free for the next step.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files, then move on.
ⓧ I'd like to double check the full code
Here is what server.js should look like:
const express = require('express');
const app = express();
const port = 3000;
app.use(express.json());
app.listen(port, () => {
console.log(`API server running at http://localhost:${port}`);
});
Here is what package.json should look like:
{
"name": "rate-limiter",
"version": "1.0.0",
"description": "API Rate Limiter from Scratch",
"main": "server.js",
"scripts": {
"start": "node server.js",
"test-flood": "node test-client.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"express": "^5.2.1"
}
}
Your environment is ready and your server is alive. Next up, you will add API endpoints and build a test client to flood them with requests.
Build an Unprotected API
Your server starts and listens, but it does nothing useful yet. Before you can protect an API from being overwhelmed, you need one that actually serves data.
In this step, you'll add real endpoints that return data, then build a test script that fires a flood of requests at them. Watching every request sail through unblocked is the problem you'll solve in the next step.
In this step, get ready to:
- Add three API routes that return data.
- Create a test client that floods your API with requests.
- Observe that every request gets through with no protection.
Add the posts endpoint
Your API needs routes that clients can call. Each route handles a specific URL path and returns JSON data.
- Switch back to server.js in VS Code.
- Find the app.listen block at the bottom of the file.
- Add this route handler above the app.listen block:
// Public read endpoint
app.get('/api/posts', (req, res) => {
res.json({
posts: [
{ id: 1, title: 'Understanding Rate Limiting' },
{ id: 2, title: 'Token Bucket Algorithm' },
{ id: 3, title: 'API Design Best Practices' }
]
});
});
What does this code do?
This registers a GET route at /api/posts. When a client hits that URL, Express calls your handler function.
The handler uses res.json() to send back a JSON object containing an array of posts. This simulates a real API returning data from a database.
Add the profile and login endpoints
- Still in server.js, add these two routes below the /api/posts route and above app.listen:
// Standard endpoint
app.get('/api/profile', (req, res) => {
res.json({
user: { id: 1, name: 'Alex', email: 'alex@example.com' }
});
});
// Auth endpoint
app.post('/api/login', (req, res) => {
res.json({ message: 'Login successful', token: 'fake-jwt-token' });
});
What does this code do?
The /api/profile route returns a fake user object. The /api/login route uses app.post() instead of app.get() because login requests send credentials in the body.
These three routes represent real-world API tiers: public reads, user data, and authentication. Later, each will get a different rate limit.
- Save server.js.
- In your terminal, stop the running server by pressing Ctrl+C.
- Restart the server by running:
node server.js
- Open your browser and visit http://localhost:3000/api/posts.
You should see a JSON response with three posts displayed in the browser.
Seeing 'Cannot GET /api/posts'?
Make sure you added the route above the app.listen block, not below it. Express registers routes in order, and app.listen must be last.
Also confirm you stopped the old server with Ctrl+C before restarting. Otherwise the new code is not running.
Why can't my browser find the /api/posts route?
✔️ Awesome, I've got everything!
Great. Double check you've saved server.js and your server is still running.
ⓧ I'd like to double check the full code
Your server.js should contain these sections in order:
- The require('express') import, app creation, and port variable at the top.
- The app.use(express.json()) middleware line.
- The /api/posts GET route returning an array of 3 posts.
- The /api/profile GET route returning a user object.
- The /api/login POST route returning a message and token.
- The app.listen block at the bottom.
Scroll up and confirm each code block is present in your file in this order.
Create the test client script
You need a way to bombard your API with many requests quickly. A custom test script lets you fire a flood of requests and see exactly what happens to each one.
- Create a new file called test-client.js in the same rate-limiter folder.
- Add the setup and request configuration by pasting this code:
const http = require('http');
const TARGET = process.argv[2] || '/api/profile';
const TOTAL_REQUESTS = parseInt(process.argv[3]) || 15;
console.log(`\nSending ${TOTAL_REQUESTS} rapid requests to http://localhost:3000${TARGET}\n`);
console.log('-'.repeat(60));
let completed = 0;
for (let i = 1; i <= TOTAL_REQUESTS; i++) {
const options = {
hostname: 'localhost',
port: 3000,
path: TARGET,
method: TARGET === '/api/login' ? 'POST' : 'GET',
headers: { 'Content-Type': 'application/json' }
};
What does this code do?
- It uses Node's built-in http module, so you don't need any extra dependencies.
- The TARGET and TOTAL_REQUESTS variables read from command-line arguments, with defaults of /api/profile and 15.
- The for loop fires all requests nearly simultaneously. Each request's method is set to POST only for the login route.
- Now add the request execution code directly below (still inside the for loop):
const req = http.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
const status = res.statusCode;
const icon = status === 200 ? 'PASS' : 'BLOCKED';
console.log(
`Request #${String(i).padStart(2)} | ${icon} | Status: ${status}`
);
completed++;
if (completed === TOTAL_REQUESTS) {
console.log('-'.repeat(60));
console.log('\nDone! All requests succeeded with no blocking.');
}
});
});
req.on('error', (err) => {
console.log(`Request #${i} | ERROR | ${err.message}`);
completed++;
});
if (TARGET === '/api/login') {
req.write(JSON.stringify({ username: 'test', password: 'test' }));
}
req.end();
}
What does this code do?
- Each request collects the response data and logs the status code with a PASS or BLOCKED label.
- The completed counter tracks how many requests finished. Once all are done, it prints a summary line.
- The error handler catches connection failures so the script does not crash if the server is not running.
- For the login endpoint specifically, the script sends a JSON body with test credentials.
- Save test-client.js.
Syntax error when saving?
Make sure the closing } of the for loop is present at the very end of the file. The second code block ends with a } that closes the loop opened in the first block.
Why is my test-client.js showing a syntax error?
✔️ Awesome, I've got everything!
Great. Make sure test-client.js is saved and ready to run.
ⓧ I'd like to double check the full code
Your test-client.js should contain these sections in order:
- The require('http') import at the top.
- The TARGET and TOTAL_REQUESTS constants reading from process.argv.
- Two console.log lines printing the header.
- A let completed = 0 counter.
- A for loop containing the options object, the http.request call with response handling, error handling, and req.end().
Scroll up and confirm both code blocks are present and the for loop's closing brace appears at the end of the file.
Flood the API and observe zero protection
Your API is live and your test script is ready. Before you run it, think about what you expect to happen when 15 requests hit the server at nearly the same instant.
- Make sure your server is still running in one terminal. If not, run node server.js again.
- Open a second terminal in VS Code by pressing Ctrl+Shift+` (macOS and Windows).
Do you think all 15 requests will succeed, or will some fail? There is no rate limiter yet.
- In the second terminal, run the test client:
node test-client.js
Every single request returns status 200 with PASS. All 15 sail through with no resistance.
Why does this matter?
Right now a single client can fire unlimited requests and your server happily responds to all of them. In production, this means one bad actor could overwhelm your API and bring it down for everyone else.
The next step fixes this by introducing a rate limiter that caps how many requests each client can make.
Seeing ERROR instead of PASS?
If every request shows ERROR with a connection refused message, your server is not running. Switch to the first terminal and run node server.js. Then try the test client again in the second terminal.
If only some requests fail, check that you saved server.js and restarted the server after adding the routes.
Why is my test client showing connection errors?
Your API happily serves every request without question. Next up, you'll build a rate limiter that finally puts a cap on this unlimited access.
Block Excess Requests with Token Bucket
In Step 2, you proved the problem: every single request sailed through with zero resistance. A single client could hammer your API all day and nothing would stop it.
The token bucket algorithm fixes this by giving each client a limited number of tokens. Each request costs one token, and tokens refill slowly over time. Once a client burns through their tokens, the API rejects further requests until tokens refill.
In this step, get ready to:
- Build a TokenBucket class that tracks and refills tokens per client.
- Create a reusable Express middleware factory that wraps the bucket logic.
- Apply the rate limiter globally and watch excess requests get blocked.
Create the TokenBucket class
Think of a token bucket like a jar of marbles. Each request takes one marble out. Marbles slowly reappear over time, but if the jar is empty, the request is denied.
What is a token bucket?
The token bucket algorithm is the industry default for rate limiting at companies like Stripe, GitHub, and AWS. It works by assigning each client a bucket with a fixed capacity.
Tokens refill at a steady rate (e.g. 1 per second), but the bucket never overflows past its max. This means idle clients can "save up" tokens for a short burst, while heavy users get throttled.
- Create a new file called rateLimiter.js in your rate-limiter folder.
- Add the beginning of the TokenBucket class with its constructor and refill method by pasting this code:
class TokenBucket {
constructor(maxTokens, refillRate) {
this.maxTokens = maxTokens;
this.tokens = maxTokens;
this.refillRate = refillRate; // tokens per second
this.lastRefill = Date.now();
}
refill() {
const now = Date.now();
const elapsed = (now - this.lastRefill) / 1000;
this.tokens = Math.min(
this.maxTokens,
this.tokens + elapsed * this.refillRate
);
this.lastRefill = now;
}
What does this code do?
- The constructor sets up the bucket with a max capacity, fills it completely, and records the current time.
- refill() calculates how many seconds have passed since the last check, then adds back tokens at the configured rate. This is "lazy refill" because it only runs when a request arrives, not on a background timer.
- Math.min() ensures the bucket never overflows past its maximum capacity.
- Directly below the refill() method (still inside the class), add the consume() method that handles each incoming request:
consume() {
this.refill();
if (this.tokens >= 1) {
this.tokens -= 1;
return {
allowed: true,
remaining: Math.floor(this.tokens),
limit: this.maxTokens,
retryAfter: null
};
}
return {
allowed: false,
remaining: 0,
limit: this.maxTokens,
retryAfter: Math.ceil((1 - this.tokens) / this.refillRate)
};
}
}
What does consume() do?
- It first calls refill() to add back any tokens earned since the last request.
- If at least 1 token is available, it subtracts one and returns allowed: true with the remaining count.
- If the bucket is empty, it returns allowed: false and calculates how many seconds until the next token arrives.
- Save rateLimiter.js.
- Verify the file loads without errors by running this command in your terminal:
node -e "require('./rateLimiter')"
If you see no output and no errors, the module loaded successfully.
Seeing a syntax error?
- Make sure the closing brace } after the consume() method closes the class. You should have two closing braces at the bottom: one for consume() and one for the class.
- Check that your terminal is in the rate-limiter project folder.
Help me debug my rateLimiter.js syntax error
Export the createRateLimiter factory
The TokenBucket class handles the algorithm, but you need a way to plug it into Express as middleware. A factory function creates a fresh middleware function pre-configured with your rate limit settings.
- Below the closing brace of the TokenBucket class in rateLimiter.js, add the factory function and module export:
function createRateLimiter({ maxTokens, refillRate }) {
const buckets = new Map();
return function rateLimiter(req, res, next) {
const key = req.ip;
if (!buckets.has(key)) {
buckets.set(key, new TokenBucket(maxTokens, refillRate));
}
const bucket = buckets.get(key);
const result = bucket.consume();
if (!result.allowed) {
return res.status(429).json({
error: 'Too Many Requests',
message: 'Rate limit exceeded.'
});
}
next();
};
}
module.exports = { createRateLimiter, TokenBucket };
What does this code do?
- createRateLimiter() accepts configuration (max tokens and refill speed) and returns a middleware function.
- The buckets Map stores one TokenBucket per client IP address. New clients get a fresh bucket on their first request.
- If consume() says the request is not allowed, the middleware responds with HTTP 429 and stops the request from reaching the route.
- If the request is allowed, next() passes it along to the actual route handler.
- Save rateLimiter.js.
- Confirm the module exports correctly by running:
node -e "console.log(typeof require('./rateLimiter').createRateLimiter)"
You should see function printed in the terminal.
Seeing 'undefined' instead of 'function'?
- Check that the module.exports line is at the very bottom of rateLimiter.js, outside the class and outside the factory function.
- Make sure the export object includes createRateLimiter (not createRateLimitor or another typo).
Help me fix my module exports
✔️ Awesome, I've got everything!
Great. Double check you have saved rateLimiter.js before moving on.
ⓧ I'd like to double check the full code
Your rateLimiter.js should contain these sections in order. Scroll up and confirm each is present:
- The TokenBucket class with constructor, refill(), and consume() methods.
- The createRateLimiter factory function that returns Express middleware.
- The module.exports line exporting both createRateLimiter and TokenBucket.
Apply the rate limiter globally and test
Your rate limiter module is ready. Now you need to wire it into your server so every incoming request passes through the bucket check.
- In server.js, find this line at the top of the file:
const express = require('express');
- Add this import directly below it:
const { createRateLimiter } = require('./rateLimiter');
- Now find this line further down:
app.use(express.json());
- Add the global rate limiter directly below it:
// Global rate limiter
const globalLimiter = createRateLimiter({ maxTokens: 10, refillRate: 1 });
app.use(globalLimiter);
What do these values mean?
- maxTokens: 10 means each client starts with 10 tokens (10 requests allowed in a burst).
- refillRate: 1 means tokens replenish at 1 per second. Once the burst is used up, the client gets roughly 1 request per second.
- app.use(globalLimiter) registers this middleware for ALL routes, so every request hits the limiter before reaching a handler.
- Save server.js.
- Restart the server to load your changes by pressing Ctrl+C in the server terminal, then running:
node server.js
You should see API server running at http://localhost:3000 printed again with no errors.
- In test-client.js, find this line near the bottom of the file:
console.log('\nDone! All requests succeeded with no blocking.');
- Replace it with:
console.log('\nDone! Observe how requests get blocked once tokens run out.');
- Save test-client.js.
Time to see your rate limiter in action. You set maxTokens to 10 and you are sending 15 requests. How many do you think will get through?
- In a second terminal, run the test client:
node test-client.js
You should see the first ~10 requests pass with status 200, and the remaining requests get blocked with status 429. The blocked responses show BLOCKED in the output.
Why approximately 10 and not exactly 10?
Because tokens refill in real time, a few milliseconds pass between requests. That means a tiny fraction of a token might refill between each request. You will typically see 10 or 11 requests pass depending on your machine speed.
All 15 requests still showing PASS?
- Make sure you restarted the server after saving server.js. Changes only take effect after a restart.
- Confirm app.use(globalLimiter) appears ABOVE the route definitions in server.js. Middleware loaded after a route does not protect that route.
- Double check that require('./rateLimiter') matches the filename exactly (case-sensitive on Linux/macOS).
Help me debug why my rate limiter is not blocking requests
Your API now has teeth. But notice something about those 429 responses. They just say "Too Many Requests" with no guidance on when to retry. A well-behaved client has no idea whether to wait 1 second or 10 minutes. Next up, you will fix that gap by adding proper rate-limit headers.
Add Rate-Limit Headers
Your rate limiter blocks excess requests, which is the core job done. But right now, a blocked client gets a bare 429 with no information about when it can try again.
Without guidance, well-behaved clients have no choice but to guess or hammer your API in a retry loop. Proper rate-limit headers let clients self-throttle before they even hit the limit. In this step, you will add those headers to every response your middleware touches.
In this step, get ready to:
- Set rate-limit headers on every response so clients always know their remaining budget.
- Add a Retry-After header on blocked responses so clients know exactly when to come back.
- Update the test client to display headers alongside each request result.
Set response headers in the middleware
Rate-limit headers are the communication channel between your server and its clients. Three headers tell the client its budget, and one more tells blocked clients how long to wait.
- In rateLimiter.js, find the createRateLimiter function.
- Locate the line that says const result = bucket.consume(); inside the returned middleware function.
- Replace everything below that line (from the if (!result.allowed) block through next();) with the following code:
// Set rate limit headers on every response
res.set('X-RateLimit-Limit', String(result.limit));
res.set('X-RateLimit-Remaining', String(result.remaining));
res.set('X-RateLimit-Reset', String(Math.ceil(Date.now() / 1000) + Math.ceil(maxTokens / refillRate)));
if (!result.allowed) {
res.set('Retry-After', String(result.retryAfter));
return res.status(429).json({
error: 'Too Many Requests',
retryAfter: result.retryAfter,
message: `Rate limit exceeded. Try again in ${result.retryAfter} second(s).`
});
}
next();
What does this code do?
- X-RateLimit-Limit tells the client the maximum tokens in the bucket. This is the ceiling they are working within.
- X-RateLimit-Remaining tells the client how many tokens they have left right now. A smart client watches this count down and slows itself before hitting zero.
- X-RateLimit-Reset is a Unix timestamp indicating when the bucket will be full again.
- Retry-After only appears on 429 responses. It tells the blocked client exactly how many seconds to wait before retrying.
- Save rateLimiter.js.
- Switch to the terminal where your server is running and press Ctrl+C to stop it.
- Restart the server by running:
node server.js
- Open a second terminal tab and verify the headers are being sent by running:
curl -i http://localhost:3000/api/profile
You should see response headers including X-RateLimit-Limit: 10, X-RateLimit-Remaining: 9, and X-RateLimit-Reset with a Unix timestamp value.
Not seeing the rate-limit headers?
- Make sure you restarted the server after saving. Changes to rateLimiter.js only take effect on the next server start.
- Check that the three res.set() lines are placed BEFORE the if (!result.allowed) block. Headers on every response must run regardless of whether the request is allowed or blocked.
- Verify you are using res.set() and not res.send() for setting headers.
Why aren't my rate-limit headers showing up in the curl response?
Update the test client to display headers
The headers are flowing, but your test client currently ignores them. You will update it to print the remaining token count and retry-after value alongside each request.
- Switch to test-client.js.
- Inside the res.on('end', ...) callback, find this code:
const status = res.statusCode;
const icon = status === 200 ? 'PASS' : 'BLOCKED';
console.log(
`Request #${String(i).padStart(2)} | ${icon} | Status: ${status}`
);
- Replace that section with the following code, which reads the headers and includes them in the output:
const remaining = res.headers['x-ratelimit-remaining'] || 'N/A';
const retryAfter = res.headers['retry-after'] || '-';
const status = res.statusCode;
const icon = status === 200 ? 'PASS' : 'BLOCKED';
console.log(
`Request #${String(i).padStart(2)} | ${icon} | Status: ${status} | Remaining: ${remaining} | Retry-After: ${retryAfter}`
);
What does this code do?
- res.headers['x-ratelimit-remaining'] reads the remaining-tokens header from the response. Note that Node.js lowercases all header names.
- res.headers['retry-after'] reads the retry guidance. It falls back to '-' for successful responses where the header is absent.
- The updated console.log appends both values to each line so you can watch the token count drain in real time.
- Save test-client.js.
Before you run the test client, predict what you will see. Will the Remaining column count down smoothly from 10? What will appear in the Retry-After column for blocked requests?
- In the second terminal tab (not the one running your server), run the test client:
node test-client.js
You should see each line now display a Remaining value counting down (9, 8, 7...) and blocked requests show a Retry-After value in seconds. The headers are working.
Seeing N/A for Remaining on every request?
- Make sure the server is still running in your first terminal tab. If you accidentally closed it, restart with node server.js.
- Check that the header name in test-client.js is all lowercase: x-ratelimit-remaining. Node.js lowercases incoming headers automatically, so the casing must match.
- Confirm that the res.set() lines in rateLimiter.js are placed before the if (!result.allowed) check, not inside it.
Why is my test client showing N/A for rate-limit headers?
✔️ Awesome, I've got everything!
Great. Double check you have saved both files.
ⓧ I'd like to double check the full code
Your rateLimiter.js should have three sections: the TokenBucket class (unchanged from Step 3), the createRateLimiter factory function (now with header-setting logic), and the module.exports line. Scroll up and confirm:
- The three res.set() lines for X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset appear BEFORE the if (!result.allowed) block.
- Inside the if (!result.allowed) block, res.set('Retry-After', String(result.retryAfter)) appears before the return res.status(429) call.
- The 429 JSON body includes retryAfter and a template-string message.
Your test-client.js should read two headers (x-ratelimit-remaining and retry-after) at the top of the res.on('end', ...) callback, and the console.log line should include | Remaining: ${remaining} | Retry-After: ${retryAfter} at the end.
Your API now communicates clearly with every client. Next up, you will replace the one-size-fits-all global limiter with per-route policies that match how real APIs protect different endpoints.
Apply Per-Route Policies
Your rate limiter now blocks excess requests and tells clients exactly when to retry with proper headers.
But right now, every endpoint gets the same 10-request limit. A login attempt carries the same weight as reading a blog post. Real APIs like Stripe and GitHub apply strict limits on authentication and generous limits on public reads to match the actual risk profile.
In this step, get ready to:
- Remove the global rate limiter and create three separate policies with different token budgets.
- Apply each policy to its matching route as route-level middleware.
- Test each endpoint to see different limits in action.
Remove the global limiter and create per-route policies
A single global limit is a blunt instrument. Login endpoints are high-risk targets for brute-force attacks, so they need aggressive throttling. Public read endpoints see lots of legitimate traffic, so they need generous room.
- In server.js, find these three lines:
// Global rate limiter
const globalLimiter = createRateLimiter({ maxTokens: 10, refillRate: 1 });
app.use(globalLimiter);
- Delete all three lines and replace them with the following code:
// Per-route rate limit policies
const strictLimiter = createRateLimiter({ maxTokens: 3, refillRate: 0.05 });
const standardLimiter = createRateLimiter({ maxTokens: 10, refillRate: 1 });
const generousLimiter = createRateLimiter({ maxTokens: 50, refillRate: 5 });
What do these policies do?
- strictLimiter allows only 3 requests before blocking, and refills at 0.05 tokens/second. That is roughly 3 requests per minute.
- standardLimiter allows 10 requests with a steady 1 token/second refill.
- generousLimiter allows 50 requests with a fast 5 tokens/second refill for high-traffic reads.
Apply each limiter as route-level middleware
Instead of a global app.use(), you pass the limiter as a second argument to each route definition. Express runs it before the route handler, so each route gets its own independent token bucket.
- In server.js, find the posts route:
// Public read endpoint
app.get('/api/posts', (req, res) => {
- Update the comment and add generousLimiter as the second argument, so the lines look like this:
// Public read endpoint - generous limits
app.get('/api/posts', generousLimiter, (req, res) => {
- Find the profile route:
// Standard endpoint
app.get('/api/profile', (req, res) => {
- Add standardLimiter as the second argument:
// Standard endpoint
app.get('/api/profile', standardLimiter, (req, res) => {
- Find the login route:
// Auth endpoint
app.post('/api/login', (req, res) => {
- Update the comment and add strictLimiter as the second argument:
// Auth endpoint - strict limits
app.post('/api/login', strictLimiter, (req, res) => {
- Save server.js.
Why this approach over a global limiter?
A global limiter with app.use() applies one bucket to every request regardless of risk. Route-level middleware lets you match the throttle to the threat.
Someone brute-forcing passwords on /api/login gets locked out in 3 attempts. Meanwhile a dashboard polling /api/posts has plenty of headroom.
✔️ Awesome, I've got everything!
Great. Double check you have saved server.js before moving on.
ⓧ I'd like to double check the full code
Scroll up through server.js and confirm each section is present, in order:
- The require imports for express and createRateLimiter.
- The app and port declarations, followed by app.use(express.json()).
- Three limiter instances: strictLimiter (maxTokens: 3), standardLimiter (maxTokens: 10), generousLimiter (maxTokens: 50).
- The /api/posts route with generousLimiter as its second argument.
- The /api/profile route with standardLimiter as its second argument.
- The /api/login route with strictLimiter as its second argument.
- The app.listen() call at the bottom.
Make sure there is no app.use(globalLimiter) line anywhere in the file.
Test each endpoint with different limits
Your test client already accepts a path and request count as command-line arguments. You can target each endpoint individually to see its unique limit.
- Stop the running server with Ctrl+C and restart it by running:
node server.js
Before you test, think about this: if you send 5 requests to /api/login and the strict limiter allows 3 tokens, how many should pass and how many should be blocked?
- Open a second terminal and test the login endpoint by running:
node test-client.js /api/login 5
You should see 3 requests pass with status 200 and 2 requests blocked with status 429. The strict limiter kicks in fast.
- Now test the posts endpoint with a flood of 55 requests by running:
node test-client.js /api/posts 55
You should see around 50 requests pass before the generous limiter blocks the rest. Different routes now enforce different limits.
All requests getting blocked?
- Make sure you restarted the server after saving your changes. The old server still has the global limiter.
- Check that you removed the app.use(globalLimiter) line entirely. If it is still there, it runs before your route-level limiters.
- Confirm each limiter variable name matches between the declaration and the route argument (e.g. generousLimiter in both places).
Help me debug why all my requests are blocked after switching to per-route limiters.
Your API now enforces tiered protection. Authentication gets locked down. Public reads get breathing room. That is the same pattern Stripe and GitHub use to protect their production APIs. You just built it from scratch.
Secret mission
Implement Sliding Window Counter
Your token bucket allows bursts up front. But what if a route needs strict per-period counting instead? Build the sliding window counter algorithm and compare its enforcement behaviour side by side with token bucket.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project runs entirely locally, so there are no ongoing costs.
Resources you used:
- The rate-limiter project directory on your Desktop (contains server.js, rateLimiter.js, slidingWindow.js, test-client.js, package.json, and node_modules/).
- The Node.js server process (if still running in your terminal).
Keep everything running
No action needed. Choose this if you are still actively building or experimenting with the rate limiter.
- Your project files stay on your Desktop in the rate-limiter folder.
- If your server is still running, you can keep sending test requests to experiment with different rate-limit values.
Pause - I'll come back to this later
Shut down the running server process to free up your terminal. Your files stay on disk for when you return.
- Switch to the terminal where your server is running.
- Press Ctrl+C to stop the server.
- Your project files remain in ~/Desktop/rate-limiter. When you come back, run node server.js from that folder to start up again.
Delete - I don't want to use this again
Remove all project files from your machine. This frees up disk space and removes everything you built.
- If your server is still running, press Ctrl+C in that terminal to stop it.
- Delete the project directory by running this command (macOS/Linux):
rm -rf ~/Desktop/rate-limiter
On Windows, run this instead:
rmdir /s /q %USERPROFILE%\Desktop\rate-limiter
- Confirm the folder is gone by running ls ~/Desktop (macOS/Linux) or dir %USERPROFILE%\Desktop (Windows). You should no longer see rate-limiter in the output.
Nice Work!
Nice Work!
You just built a production-grade API rate limiter from scratch in Node.js. This is the same algorithm used by Stripe, GitHub, and AWS to protect their APIs from being overwhelmed.
You've learned how to:
- Build a token bucket rate limiter from scratch and apply it as Express middleware that blocks excess traffic with HTTP 429 responses.
- Implement proper rate-limit headers (X-RateLimit-Remaining, Retry-After) that let clients self-throttle before they hit the limit.
- Design per-route policies that match real-world API security: strict limits for authentication, generous limits for public reads.
- Secret Mission: Implement the sliding window counter algorithm and compare its enforcement behaviour to token bucket.
Ready to quiz yourself?