Build a Token Bucket Rate Limiter
Build a token bucket rate limiter from scratch as Express middleware.
Introduction
30 Second Summary
Every time you refresh a social media feed or call an API, something behind the scenes decides whether your request gets through or gets rejected. That invisible gatekeeper is a rate limiter.
In this project, you will build a token bucket rate limiter from scratch using Node.js and Express. You will also build a load test script that fires rapid requests to watch your limiter accept bursts and then throttle traffic in real time.
What You'll Build
A small Express API protected by a custom token bucket rate limiter middleware that you write from scratch, with configurable burst capacity and refill rate.
By the end of this project, you'll have:
- An Express API server with sample endpoints protected by your rate limiter.
- A token bucket algorithm implemented as reusable middleware that returns proper HTTP headers so clients know their remaining quota.
- A load test script that fires rapid requests and logs burst acceptance, throttling, and gradual recovery.
- Secret Mission: Add per-route rate limiting with different configurations per endpoint.
Are there any prerequisites?
You should be familiar with JavaScript and basic Node.js/Express concepts. You will also need VS Code and Node.js installed on your machine.
Set Up the Project
Before building the rate limiter itself, you need a project to attach it to. This step creates a minimal Express API server that you'll protect in later steps.
You'll set up a Node.js project, install Express, and write a basic server that responds to requests. This gives you a working foundation to layer the rate limiter onto.
In this step, get ready to:
- Create and initialize the rate-limiter project directory.
- Install Express as a dependency.
- Create the project files and verify the server runs.
Create the project directory and initialize it
Every Node.js project starts with a folder and a package.json file that tracks its dependencies and scripts. You'll create both now.
- Open a terminal in VS Code by pressing Ctrl+` (backtick key, top-left of keyboard).
- 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 package.json file by running:
npm init -y
What does this command do?
The npm init -y command creates a package.json file with default values. This file is the manifest for your project. It tracks which packages your project depends on and defines scripts you can run.
- Install Express by running:
npm install express@5.1.0
You should see output confirming that packages were added and a node_modules folder appears in your project.
Why Express 5.1.0?
Express 5 is the latest major version. Pinning to 5.1.0 ensures everyone following this project gets the same behavior. Express 5 requires Node.js 18 or higher, which you already have installed.
Seeing an npm error?
If you see a permission error, try running the command with sudo on macOS/Linux. If you see a network error, check your internet connection and try again.
help me fix my npm install error
Create the project file structure
Your project needs three files. server.js runs the API, rateLimiter.js holds the rate limiting logic, and loadTest.js tests it under load.
- Create all three files by running:
macOS
touch server.js rateLimiter.js loadTest.js
Windows
New-Item -Path server.js, rateLimiter.js, loadTest.js -ItemType File
- Confirm the files exist by running:
ls
You should see server.js, rateLimiter.js, loadTest.js, package.json, and node_modules in the output.
- Open the rate-limiter folder in VS Code by clicking File in the top menu bar.
- Click Open Folder.
- Select the rate-limiter folder on your Desktop, then click Open.
VS Code reloads with the project folder as your workspace. You should see all three files in the Explorer sidebar on the left.
- Open server.js by clicking it in the Explorer sidebar.
- Paste the following code into server.js:
const express = require('express');
const app = express();
const PORT = 3000;
// Respond with a welcome message at the root route
app.get('/', (req, res) => {
res.json({ message: 'Token Bucket Rate Limiter API' });
});
// Start the server and log the URL
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
What does this code do?
- require('express') loads the Express framework you just installed.
- app.get('/', ...) defines a route that responds to GET requests at the root URL with a JSON message.
- app.listen(PORT, ...) starts the server on port 3000 and prints a confirmation message to the terminal.
- Save server.js by pressing Cmd+S (macOS) or Ctrl+S (Windows).
- Open the VS Code terminal by pressing Ctrl+` (backtick).
- Start the server by running:
node server.js
You should see Server running on http://localhost:3000 printed in the terminal.
Seeing an error when starting the server?
If you see a message about not finding the express module, make sure you ran npm install express@5.1.0 inside the rate-limiter folder. Check that a node_modules folder exists in your project directory.
If you see a message about the address already being in use, another process is using port 3000. Close other servers or terminals you may have running.
help me fix my Express server startup error
✔️ Awesome, I've got everything!
Great. Double check you've saved server.js and the server is running in your terminal.
ⓧ I'd like to double check the full code
const express = require('express');
const app = express();
const PORT = 3000;
app.get('/', (req, res) => {
res.json({ message: 'Token Bucket Rate Limiter API' });
});
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
Verify with a quick browser test
The server is running, but you should confirm it actually responds to requests from outside the terminal.
- Open your web browser.
- Navigate to http://localhost:3000/ in the address bar.
You should see the JSON response displayed in your browser:
{"message":"Token Bucket Rate Limiter API"}
Seeing a blank page or connection error?
Make sure the server is still running in your terminal. You should see Server running on http://localhost:3000 with no error messages after it.
If the terminal shows your cursor (ready for new input), the server stopped. Run node server.js again and refresh the browser.
help me connect to my local Express server
Your project is set up and your server is confirmed working. Next up, you'll build the token bucket algorithm that controls how many requests get through.
Implement the Token Bucket Algorithm
Your Express server is up and running. Now it's time to build the core of this project: the token bucket rate limiter that decides which requests get through and which get rejected.
The token bucket algorithm works like a bucket that holds a fixed number of tokens. Each request costs one token. The bucket refills at a steady rate over time. This separates burst capacity (how many tokens the bucket holds) from sustained throughput (how fast it refills).
In this step, get ready to:
- Implement the TokenBucket class with refill and consume logic.
- Build a configurable middleware factory that tracks clients by IP.
- Wire the rate limiter into your Express server as global middleware.
Implement the TokenBucket class
The TokenBucket class is the heart of the algorithm. It tracks how many tokens are available and calculates refills based on elapsed time.
- Open rateLimiter.js in VS Code and add the following class:
class TokenBucket {
constructor(capacity, refillRate) {
// Maximum tokens the bucket can hold (burst size)
this.capacity = capacity;
// Tokens added per second (sustained rate)
this.refillRate = refillRate;
// Start with a full bucket
this.tokens = capacity;
// Track when we last refilled
this.lastRefillTime = Date.now();
}
refill() {
// Calculate how much time has passed since the last refill
const now = Date.now();
const elapsedSeconds = (now - this.lastRefillTime) / 1000;
// Add tokens proportional to elapsed time
const tokensToAdd = elapsedSeconds * this.refillRate;
// Cap at bucket capacity so it never overflows
this.tokens = Math.min(this.capacity, this.tokens + tokensToAdd);
this.lastRefillTime = now;
}
tryConsume() {
// Always refill before checking availability
this.refill();
if (this.tokens >= 1) {
// Consume one token for this request
this.tokens -= 1;
return {
allowed: true,
remaining: Math.floor(this.tokens),
retryAfter: 0
};
}
// Not enough tokens - calculate wait time
const retryAfter = (1 - this.tokens) / this.refillRate;
return {
allowed: false,
remaining: 0,
retryAfter: Math.ceil(retryAfter)
};
}
}
What does this code do?
The constructor sets up a bucket with a given capacity and refill rate. The bucket starts full, so the first burst of requests always passes.
The refill() method calculates how many seconds have passed since the last check. It adds tokens proportional to that time, capped at the bucket's capacity so it never overflows.
The tryConsume() method refills the bucket first, then checks if a token is available. If yes, it deducts one token and returns success. If not, it calculates how many seconds the client must wait until a token becomes available.
- Save rateLimiter.js.
You won't see a change yet. The next chunk builds the middleware factory that uses this class.
Why token bucket instead of a simple counter?
A simple counter resets at fixed intervals (e.g., "10 requests per minute"), but that creates unfair edge cases. A user could send 10 requests at 0:59 and 10 more at 1:01, getting 20 requests in 2 seconds.
The token bucket smooths this out. Tokens refill continuously, so burst and sustained rates are independent tuning knobs. This is the same algorithm Stripe, GitHub, and AWS use to protect their public APIs.
Build the configurable middleware factory
The TokenBucket class handles one client. You need a factory function that creates a separate bucket for each client IP and wraps it all in Express middleware.
- In rateLimiter.js, add the following code below the TokenBucket class:
function createRateLimiter(options) {
const { capacity = 10, refillRate = 2 } = options;
// One bucket per client IP
const buckets = new Map();
return (req, res, next) => {
// Identify the client by their IP address
const key = req.ip;
// Create a new bucket if this is a new client
if (!buckets.has(key)) {
buckets.set(key, new TokenBucket(capacity, refillRate));
}
const bucket = buckets.get(key);
const result = bucket.tryConsume();
if (result.allowed) {
// Set rate limit headers so the client knows their quota
res.set('X-RateLimit-Limit', String(capacity));
res.set('X-RateLimit-Remaining', String(result.remaining));
next();
} else {
// Reject with 429 and tell the client when to retry
res.set('X-RateLimit-Limit', String(capacity));
res.set('X-RateLimit-Remaining', '0');
res.set('Retry-After', String(result.retryAfter));
res.status(429).json({
error: 'Too Many Requests',
retryAfter: result.retryAfter
});
}
};
}
module.exports = { createRateLimiter };
What does this code do?
The createRateLimiter function accepts configuration options and returns an Express middleware function with the standard (req, res, next) signature. This is the configurable middleware factory pattern from the Express docs.
The Map stores one bucket per client IP. When a request arrives, the middleware looks up (or creates) the client's bucket, calls tryConsume(), and either lets the request through or responds with HTTP 429.
The X-RateLimit-Limit and X-RateLimit-Remaining headers tell clients their total quota and how many requests they have left. The Retry-After header tells rejected clients how long to wait.
- Save rateLimiter.js.
- Verify the module exports correctly by running this command in your terminal:
node -e "const { createRateLimiter } = require('./rateLimiter'); console.log(typeof createRateLimiter)"
You should see function printed in the terminal.
Seeing a syntax error or 'is not a function'?
- Make sure the module.exports = { createRateLimiter }; line is at the very bottom of the file, outside the function.
- Check that the createRateLimiter function is defined after the TokenBucket class, since it references it.
- Confirm all curly braces are closed properly. The function has nested if/else blocks inside the returned middleware.
Help me debug my rateLimiter.js exports
✔️ Awesome, I've got everything!
Great. Make sure you've saved rateLimiter.js before moving on.
ⓧ I'd like to double check the full code
class TokenBucket {
constructor(capacity, refillRate) {
this.capacity = capacity;
this.refillRate = refillRate;
this.tokens = capacity;
this.lastRefillTime = Date.now();
}
refill() {
const now = Date.now();
const elapsedSeconds = (now - this.lastRefillTime) / 1000;
const tokensToAdd = elapsedSeconds * this.refillRate;
this.tokens = Math.min(this.capacity, this.tokens + tokensToAdd);
this.lastRefillTime = now;
}
tryConsume() {
this.refill();
if (this.tokens >= 1) {
this.tokens -= 1;
return {
allowed: true,
remaining: Math.floor(this.tokens),
retryAfter: 0
};
}
const retryAfter = (1 - this.tokens) / this.refillRate;
return {
allowed: false,
remaining: 0,
retryAfter: Math.ceil(retryAfter)
};
}
}
function createRateLimiter(options) {
const { capacity = 10, refillRate = 2 } = options;
const buckets = new Map();
return (req, res, next) => {
const key = req.ip;
if (!buckets.has(key)) {
buckets.set(key, new TokenBucket(capacity, refillRate));
}
const bucket = buckets.get(key);
const result = bucket.tryConsume();
if (result.allowed) {
res.set('X-RateLimit-Limit', String(capacity));
res.set('X-RateLimit-Remaining', String(result.remaining));
next();
} else {
res.set('X-RateLimit-Limit', String(capacity));
res.set('X-RateLimit-Remaining', '0');
res.set('Retry-After', String(result.retryAfter));
res.status(429).json({
error: 'Too Many Requests',
retryAfter: result.retryAfter
});
}
};
}
module.exports = { createRateLimiter };
Wire the middleware into the Express server
The rate limiter module is ready. Now you need to import it into your server and apply it globally so every incoming request passes through the token bucket check.
- Switch back to server.js in VS Code.
- Replace the entire contents of server.js with the following code:
const express = require('express');
// Import the rate limiter factory from our module
const { createRateLimiter } = require('./rateLimiter');
const app = express();
const PORT = 3000;
// Parse JSON request bodies
app.use(express.json());
// Apply the rate limiter globally: 10 token burst, 2 tokens/sec refill
const limiter = createRateLimiter({ capacity: 10, refillRate: 2 });
app.use(limiter);
app.get('/', (req, res) => {
res.json({ message: 'Token Bucket Rate Limiter API' });
});
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
What's happening here?
- createRateLimiter({ capacity: 10, refillRate: 2 }) means the bucket holds 10 tokens (allowing a burst of 10 rapid requests) and refills at 2 tokens per second.
- app.use(limiter) registers the middleware globally. Every request to any route passes through the rate limiter first.
- app.use(express.json()) is placed before the rate limiter so the server can parse JSON request bodies for future POST routes.
- Save server.js.
- If your server is still running from the previous step, stop it by pressing Ctrl+C in the terminal.
- Restart the server by running:
node server.js
You should see Server running on http://localhost:3000 printed in the terminal with no errors.
Server won't start?
- If you see Cannot find module './rateLimiter', make sure rateLimiter.js is saved in the same folder as server.js.
- If you see EADDRINUSE, the previous server is still running. Press Ctrl+C in the terminal where it's running, then try again.
- If you see a syntax error with a line number, double check your rateLimiter.js against the full code tab above.
Help me fix my server startup error
✔️ Awesome, I've got everything!
Great. Make sure you've saved server.js and the server is running without errors.
ⓧ I'd like to double check the full code
const express = require('express');
const { createRateLimiter } = require('./rateLimiter');
const app = express();
const PORT = 3000;
app.use(express.json());
const limiter = createRateLimiter({ capacity: 10, refillRate: 2 });
app.use(limiter);
app.get('/', (req, res) => {
res.json({ message: 'Token Bucket Rate Limiter API' });
});
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
Your rate limiter is wired in and ready to protect your API. Next up, you'll add proper rate limit headers and sample routes so you can actually see the bucket draining in action.
Add Rate Limit Response Headers
Your token bucket algorithm is working and the middleware is wired into your server. Clients are being rate limited behind the scenes, but they have no way to know how many requests they have left or when to try again.
The answer is HTTP response headers. In this step, you'll understand the standard rate limit headers your middleware already sends, then add real API endpoints to test against so you can see everything in action.
In this step, get ready to:
- Understand the standard rate limit headers on successful responses.
- Understand the 429 rejection format for throttled requests.
- Add sample API routes and verify headers appear in responses.
Understand the rate limit headers on allowed requests
When a client sends a request and the bucket still has tokens, your middleware lets the request through. But it also tells the client how much quota they have left via response headers.
- In VS Code, switch back to rateLimiter.js and find the if (result.allowed) block inside the middleware function:
if (result.allowed) {
res.set('X-RateLimit-Limit', String(capacity));
res.set('X-RateLimit-Remaining', String(result.remaining));
next();
}
What do these headers do?
- X-RateLimit-Limit tells the client the maximum bucket capacity (in this case, 10). The client knows how many total requests their bucket can hold.
- X-RateLimit-Remaining tells the client how many tokens are left after the current request. This lets the client pace itself before hitting the limit.
These are the same headers you see from APIs like GitHub and Stripe. Clients use them to implement backoff strategies and avoid getting throttled.
Understand the 429 rejection response
When the bucket is empty and a request arrives, the middleware blocks it. The client needs to know two things: that they were throttled, and when they can try again.
- Still in rateLimiter.js, find the else block directly below:
} else {
res.set('X-RateLimit-Limit', String(capacity));
res.set('X-RateLimit-Remaining', '0');
res.set('Retry-After', String(result.retryAfter));
res.status(429).json({
error: 'Too Many Requests',
retryAfter: result.retryAfter
});
}
What does the rejection do?
- HTTP status 429 is the standard code that means "Too Many Requests." Well-behaved clients recognize this and stop sending for a while.
- Retry-After header tells the client exactly how many seconds to wait before their next token arrives. This is calculated from the refill rate.
- The JSON body includes the same retryAfter value so programmatic clients can parse it easily without reading headers.
Why include both headers and a JSON body?
HTTP infrastructure (proxies, CDNs, load balancers) reads headers automatically. Application code prefers to parse JSON. By including both, your rate limiter works for the widest range of clients.
Add sample API routes to test against
Right now your server only has a root route. To properly test the rate limiter, you need endpoints that simulate real API work.
- In VS Code, switch to server.js.
- Find the app.listen call at the bottom of the file. Add these two routes directly above it:
app.get('/api/data', (req, res) => {
res.json({
data: [
{ id: 1, name: 'Item One' },
{ id: 2, name: 'Item Two' },
{ id: 3, name: 'Item Three' }
],
timestamp: new Date().toISOString()
});
});
app.get('/api/health', (req, res) => {
res.json({ status: 'healthy', uptime: process.uptime() });
});
What do these routes do?
- GET /api/data returns sample JSON data with a timestamp. This simulates a typical data endpoint.
- GET /api/health returns server status and uptime. Health checks are standard in production APIs.
Both routes are protected by the global rate limiter you applied with app.use(limiter) earlier. Every request to any route passes through the middleware first.
- Save server.js.
- In your terminal, stop the running server by pressing Ctrl+C.
- Restart the server by running:
node server.js
- Open a second terminal and test the new endpoint by running:
curl -i http://localhost:3000/api/data
You should see response headers including X-RateLimit-Limit: 10 and X-RateLimit-Remaining: 9 (or a nearby number), followed by the JSON data body.
- Run the same command 10 more times rapidly to drain the bucket.
- On the 11th request, you should see a 429 status code with a Retry-After header and the JSON error body.
Not seeing rate limit headers?
- Make sure you saved server.js and restarted the server with Ctrl+C then node server.js. Changes require a restart.
- If you see Connection refused, the server isn't running. Check your first terminal for error messages.
- If the headers are missing but you get a response, confirm the app.use(limiter) line is above your route definitions in server.js. Middleware order matters in Express.
Still stuck? Why aren't my rate limit headers showing up in curl responses?
✔️ Awesome, I've got everything!
Great. Double check that you've saved server.js and your server is running.
ⓧ I'd like to double check the full code
const express = require('express');
const { createRateLimiter } = require('./rateLimiter');
const app = express();
const PORT = 3000;
app.use(express.json());
const limiter = createRateLimiter({ capacity: 10, refillRate: 2 });
app.use(limiter);
app.get('/', (req, res) => {
res.json({ message: 'Token Bucket Rate Limiter API' });
});
app.get('/api/data', (req, res) => {
res.json({
data: [
{ id: 1, name: 'Item One' },
{ id: 2, name: 'Item Two' },
{ id: 3, name: 'Item Three' }
],
timestamp: new Date().toISOString()
});
});
app.get('/api/health', (req, res) => {
res.json({ status: 'healthy', uptime: process.uptime() });
});
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});
Your rate limiter now speaks a language clients understand. Headers tell them their remaining quota, and the 429 response tells them exactly when to come back. Next up, you'll build a load test script that hammers these endpoints and visualizes the bucket draining in real time.
Build a Load Test Script
Your rate limiter is working. Requests that exceed the bucket capacity get rejected with a 429 status and proper headers.
Now you need proof you can see with your own eyes. In this step, you will build a load test script that fires rapid bursts of requests at your API and logs exactly which ones pass and which get throttled.
In this step, get ready to:
- Write a request-sending function using Node.js's built-in fetch API.
- Fire two bursts of traffic separated by a pause to demonstrate refill behavior.
- Observe console output showing burst acceptance, throttling, and recovery.
Write a script that fires rapid sequential requests
Your load test needs two helper functions: one to send a request and capture the rate limit headers, and one to format the results into readable console output.
Node.js 18+ includes a global fetch API, so you do not need to install any HTTP client library.
- In VS Code, open loadTest.js and add the sendRequest() function by pasting this code:
const BASE_URL = 'http://localhost:3000';
async function sendRequest(url, method = 'GET') {
const response = await fetch(url, { method });
const data = await response.json();
// Read the rate limit headers from the response
const remaining = response.headers.get('X-RateLimit-Remaining');
const retryAfter = response.headers.get('Retry-After');
return {
status: response.status,
remaining,
retryAfter,
data
};
}
What does this code do?
BASE_URL stores the server address so you only define it once.
sendRequest() uses the global fetch to hit a URL, then pulls out X-RateLimit-Remaining and Retry-After from the response headers. It returns everything your logging function needs to decide if the request passed or was rejected.
- Below the sendRequest() function, add the logResult() function by pasting this code:
function logResult(index, result) {
const status = result.status === 200 ? 'PASS' : 'REJECTED';
const info = result.status === 200
? `remaining: ${result.remaining}`
: `retry after: ${result.retryAfter}s`;
console.log(` Request ${String(index).padStart(2)}: [${status}] ${info}`);
}
What does this code do?
logResult() formats each request's outcome into a single readable line. If the status is 200, it prints PASS and shows the remaining tokens. If it is 429, it prints REJECTED and shows how long until a token is available.
padStart(2) aligns single-digit numbers so your console output lines up neatly.
- Save loadTest.js.
You will not run this file yet. The next chunk adds the main test logic that calls these helpers.
Add a delayed burst to show refill behavior
A single burst of requests would only show the bucket draining. To demonstrate the full lifecycle, you need a second burst after a pause. This proves the bucket refills over time.
- Below the logResult() function in loadTest.js, add the sleep() helper and the main runLoadTest() function by pasting this code:
async function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function runLoadTest() {
console.log('=== Token Bucket Rate Limiter Load Test ===\n');
// Burst 1: send more requests than the bucket can hold
console.log('--- Burst 1: 15 rapid requests (bucket capacity: 10) ---');
for (let i = 1; i <= 15; i++) {
const result = await sendRequest(`${BASE_URL}/api/data`);
logResult(i, result);
}
// Pause to let the bucket refill
console.log('\n--- Waiting 3 seconds for bucket to refill... ---');
console.log(' (refill rate: 2 tokens/sec, expecting ~6 new tokens)\n');
await sleep(3000);
// Burst 2: send fewer requests to show partial refill
console.log('--- Burst 2: 8 requests after refill ---');
for (let i = 1; i <= 8; i++) {
const result = await sendRequest(`${BASE_URL}/api/data`);
logResult(i, result);
}
console.log('\n=== Load Test Complete ===');
}
runLoadTest().catch(console.error);
What does this code do?
sleep() wraps setTimeout in a Promise so you can await it inside an async function.
runLoadTest() fires 15 rapid requests (5 more than the bucket capacity of 10), waits 3 seconds for the bucket to refill about 6 tokens, then fires 8 more requests. The output shows exactly when the bucket empties and how it recovers.
The .catch(console.error) at the bottom ensures any unhandled errors print to the console instead of silently failing.
- Save loadTest.js.
Seeing a syntax error when you save?
Make sure your template literal strings use backticks (`) and not regular quotes. The ${} interpolation syntax only works inside backtick strings.
help me fix a syntax error in my loadTest.js file
✔️ Awesome, I've got everything!
Great. Double check you have saved loadTest.js before moving on.
ⓧ I'd like to double check the full code
Here is the complete loadTest.js file. Compare yours against it:
const BASE_URL = 'http://localhost:3000';
async function sendRequest(url, method = 'GET') {
const response = await fetch(url, { method });
const data = await response.json();
const remaining = response.headers.get('X-RateLimit-Remaining');
const retryAfter = response.headers.get('Retry-After');
return {
status: response.status,
remaining,
retryAfter,
data
};
}
function logResult(index, result) {
const status = result.status === 200 ? 'PASS' : 'REJECTED';
const info = result.status === 200
? `remaining: ${result.remaining}`
: `retry after: ${result.retryAfter}s`;
console.log(` Request ${String(index).padStart(2)}: [${status}] ${info}`);
}
async function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function runLoadTest() {
console.log('=== Token Bucket Rate Limiter Load Test ===\n');
console.log('--- Burst 1: 15 rapid requests (bucket capacity: 10) ---');
for (let i = 1; i <= 15; i++) {
const result = await sendRequest(`${BASE_URL}/api/data`);
logResult(i, result);
}
console.log('\n--- Waiting 3 seconds for bucket to refill... ---');
console.log(' (refill rate: 2 tokens/sec, expecting ~6 new tokens)\n');
await sleep(3000);
console.log('--- Burst 2: 8 requests after refill ---');
for (let i = 1; i <= 8; i++) {
const result = await sendRequest(`${BASE_URL}/api/data`);
logResult(i, result);
}
console.log('\n=== Load Test Complete ===');
}
runLoadTest().catch(console.error);
Run the load test and observe the output
You need two terminal windows: one running the server, and one running the load test against it.
- Make sure your server is still running in its terminal. If you stopped it earlier, start it again by running:
node server.js
- Open a second terminal in VS Code by pressing Cmd+Shift+` (macOS) or Ctrl+Shift+` (Windows).
- Run the load test in the new terminal:
node loadTest.js
- Watch the output. You should see three distinct phases:
- Requests 1-10 show [PASS] with remaining tokens counting down from 9 to 0.
- Requests 11-15 show [REJECTED] with a retry-after value.
- After the 3-second pause, about 6 requests pass (the bucket refilled at 2 tokens per second), then the rest get rejected again.
Why does this prove the algorithm works?
The output maps directly to the two token bucket parameters. The capacity (10) controls how many burst requests pass before throttling kicks in. The refill rate (2 tokens/sec) controls how quickly the bucket recovers.
Three seconds of refill at 2 tokens/sec gives you approximately 6 new tokens. The output proves both values are working exactly as configured.
All requests showing REJECTED?
If you tested the API manually before running loadTest.js, your bucket may already be drained. Stop the server with Ctrl+C, restart it with node server.js (this resets all buckets), then run the load test again.
help me debug my load test showing all requests rejected
Seeing fetch is not defined?
The global fetch API requires Node.js 18 or higher. Check your version by running node --version in your terminal. If you are below v18, upgrade Node.js to continue.
help me fix fetch is not defined in my Node.js script
Your load test proves the token bucket algorithm is working exactly as designed. You can see burst tolerance, throttling, and recovery all in one script. Next up, you will tackle the Secret Mission and apply different rate limits to different routes.
Secret mission
Add Per-Route Rate Limiting
Real production APIs don't apply the same rate limit to every endpoint. A login route needs much tighter throttling than a read-only data endpoint. Can you configure your rate limiter to enforce different limits on different routes?
Clean Up Your Resources
Clean Up Your Resources
This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Running Node.js server process on port 3000.
- The rate-limiter project folder on your Desktop (including node_modules).
Keep everything running
No action needed. Choose this if you're still actively building or experimenting with the rate limiter configuration.
- Your server continues running on port 3000 until you close the terminal or press Ctrl+C.
- Your project files remain in the rate-limiter folder on your Desktop.
Pause - I'll come back to this later
Stop the running server to free up the port, but keep your project files for later.
- Switch to the terminal where your server is running and press Ctrl+C to stop it.
- Your project files stay in ~/Desktop/rate-limiter. When you're ready to resume, run node server.js from that folder.
Delete - I don't want to use this again
Remove all project files and start fresh.
- Switch to the terminal where your server is running and press Ctrl+C to stop it.
- Delete the project folder by running the command for your operating system.
macOS
rm -rf ~/Desktop/rate-limiter
Windows
rmdir /s /q %USERPROFILE%\Desktop\rate-limiter
Nice Work!
Nice Work!
Nice work! You just built a production-grade token bucket rate limiter from scratch and proved it works under load.
You've learned how to:
- Build a token bucket rate limiter from scratch, understanding how capacity controls burst tolerance and refill rate controls sustained throughput.
- Implement it as reusable Express middleware with proper HTTP 429 responses and rate limit headers.
- Build a load test script that visualizes the bucket draining and refilling in real time.
- Secret Mission: Applied per-route rate limiting with stricter limits on sensitive endpoints, mirroring real production API patterns.
Ready to quiz yourself?