Build a Rate-Limited API

Implement a Redis-backed token bucket rate limiter in Express.

Introduction

30 Second Summary

Every time you send too many requests to an API, something behind the scenes decides you've had enough and blocks you. That invisible gatekeeper is a rate limiter.

In this project, you will build your own rate limiter from scratch using the token bucket algorithm. It's the same approach Stripe, GitHub, and AWS use to protect their production APIs.

What You'll Build

You'll fire a burst of 15 requests at your Express API and watch your Redis-backed token bucket allow exactly 10 through while rejecting the rest with informative headers.

By the end of this project, you'll have:

  • An API where you fire 15 rapid requests and see exactly 10 succeed while 5 are rejected with proper 429 responses.
  • Every response includes rate limit headers telling the client how many requests they have left and when their budget resets.
  • A burst test script that demonstrates the limiter working in real time with color-coded terminal output.
  • Secret Mission: Add per-endpoint tiered limits so auth routes get stricter protection than read routes.

Are there any prerequisites?

You should be comfortable with Node.js and building APIs with Express. No prior Redis experience is needed.

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 the same rate limiting system that protects APIs at Stripe, GitHub, and AWS. You'll start with a naive approach, watch it break under pressure, then replace it with a production-grade Redis-backed token bucket that uses atomic Lua scripting to enforce limits correctly.

Set Up Redis and the Project

To build a rate limiter backed by Redis, you first need the tools in place. Redis serves as the shared data store that tracks how many requests each client has made.

In this step, you'll get Redis running locally, create a Node.js project with the right packages, and wire up a basic Express server that you'll protect with rate limiting in the next step.

In this step, get ready to:
  • Install Redis and confirm it responds to a ping.
  • Scaffold a Node.js project with Express and ioredis.
  • Start a minimal Express API that returns JSON on two routes.
Install Redis and verify the connection

Redis is an in-memory data store that your rate limiter will use to track request counts. It needs to be running as a background service before your app can connect to it.

  • Check if Redis is already running by entering this command in your terminal:
redis-cli ping

✔️ I see PONG

Redis is already running on your machine. You're ready to move on to creating the project.

ⓧ Command not found or connection refused

Redis isn't installed or isn't running yet. Install it and start it as a background service.

  • Install Redis via Homebrew by running:
brew install redis
  • Start Redis as a background service by running:
brew services start redis
  • Verify Redis is running by entering:
redis-cli ping

You should see PONG printed in your terminal.

Still not seeing PONG?

Make sure Homebrew finished the install without errors. Look for a green checkmark or a "successfully installed" message in the output above.

If brew services start redis didn't produce an error but redis-cli ping shows "connection refused", wait a few seconds and try again. Redis may need a moment to start.

help me troubleshoot Redis not responding to ping on macOS

Create the project directory and install dependencies

Now that Redis is running, you need a project folder with Express for the API and ioredis for connecting to Redis.

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your project folder and move into it by running these commands:
mkdir rate-limited-api
cd rate-limited-api
  • Initialize a new Node.js project by running:
npm init -y

You should see output confirming a package.json file was created in your folder.

  • Install Express and ioredis by running:
npm install express@5.2.1 ioredis@5.11.1

What are these packages?

Express 5.2.1 is a web framework for building APIs. It handles routing and middleware.

ioredis 5.11.1 is a Redis client for Node.js. It lets your JavaScript code talk to the Redis server you just started.

You should see output listing the installed packages and their sizes.

Seeing permission errors or EACCES?

Make sure you're inside the rate-limited-api folder (check with pwd). If you see EACCES errors, your global npm configuration may have a permissions issue.

help me fix npm permission errors when installing packages

Create server.js and start the server

With dependencies installed, you'll create the Express server file and a clean package.json with the right start script.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Cursor and press Enter to open it.
  • In Cursor, click File in the top menu bar.
  • Click Open Folder.
  • Select the rate-limited-api folder on your Desktop, then click Open.
  • In the Explorer sidebar, click on package.json to open it.
  • Select all the content and replace it with this:
{
  "name": "rate-limited-api",
  "version": "1.0.0",
  "description": "A rate-limited API using the token bucket algorithm",
  "main": "server.js",
  "scripts": {
    "start": "node server.js",
    "test:burst": "node testBurst.js"
  },
  "dependencies": {
    "express": "5.2.1",
    "ioredis": "5.11.1"
  }
}

What does this configure?

The start script lets you run npm start to launch your server instead of typing node server.js every time.

The test:burst script is a shortcut for the burst test you'll write later to stress-test the rate limiter.

  • Save package.json.
  • Create a new file by clicking File then New File.
  • Name the file server.js and paste the following code into it:
const express = require('express');

const app = express();
const PORT = 3000;

// Return JSON data with a timestamp
app.get('/api/data', (req, res) => {
  res.json({
    message: 'Here is your data!',
    timestamp: new Date().toISOString()
  });
});

// Simple health check endpoint
app.get('/api/health', (req, res) => {
  res.json({ status: 'ok' });
});

// Start the server
app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

What does this code do?

This creates an Express app with two routes. /api/data returns a JSON object with a message and the current time. /api/health returns a simple status check.

The server listens on port 3000 and prints a confirmation message when it starts.

  • Save server.js.

✔️ Awesome, I've got everything!

Great. Double check you've saved both files before moving on.

ⓧ I'd like to double check the full code

Here's what server.js should look like:

const express = require('express');

const app = express();
const PORT = 3000;

app.get('/api/data', (req, res) => {
  res.json({
    message: 'Here is your data!',
    timestamp: new Date().toISOString()
  });
});

app.get('/api/health', (req, res) => {
  res.json({ status: 'ok' });
});

app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

And here's what package.json should look like:

{
  "name": "rate-limited-api",
  "version": "1.0.0",
  "description": "A rate-limited API using the token bucket algorithm",
  "main": "server.js",
  "scripts": {
    "start": "node server.js",
    "test:burst": "node testBurst.js"
  },
  "dependencies": {
    "express": "5.2.1",
    "ioredis": "5.11.1"
  }
}

Before you run the server, what do you think you'll see printed in the terminal?

  • Start the server by running this in your terminal (make sure you're in the rate-limited-api folder):
npm start

You should see this message printed in your terminal:

Server running on http://localhost:3000
  • Open a second terminal tab or window (the server keeps the first one busy).
  • Test the data endpoint by running:
curl http://localhost:3000/api/data

You should see a JSON response with a message and a timestamp.

  • Test the health endpoint by running:
curl http://localhost:3000/api/health

You should see {"status":"ok"} in the output.

Server not starting or port already in use?

If you see "EADDRINUSE", another process is using port 3000. Find it with lsof -i :3000 to get the PID, then kill <PID> to free the port.

If you see "Cannot find module 'express'", make sure you ran npm install inside the rate-limited-api folder and that node_modules exists there.

help me fix my Express server not starting

Your environment is ready. Redis is listening for connections and your Express API is serving JSON. Next up, you'll build a rate limiter to protect those endpoints.

Add a Naive Rate Limiter

Your API is live and serving data. Right now, any client can hit it as many times as they want with zero consequences.

Before jumping to the production-grade solution, you'll build the simplest possible rate limiter first. Seeing it work (and later break) gives you the intuition for why the real solution needs to be more sophisticated.

In this step, get ready to:
  • Build a fixed-window rate limiter using a JavaScript Map.
  • Test the limiter by firing rapid requests and watching it reject the extras.
Implement the fixed-window rate limiter

A fixed-window rate limiter divides time into chunks (windows) and counts how many requests each client makes within the current window. Once the count hits the maximum, every additional request gets rejected until the window resets.

  • In Cursor, switch back to server.js.
  • Find the line that says const PORT = 3000; and add the following code below it (above the route definitions):
// Store request counts per IP address
const requestCounts = new Map();
// Time window: 60 seconds
const WINDOW_MS = 60 * 1000;
// Max requests allowed per window
const MAX_REQUESTS = 10;

// Middleware that tracks and limits requests per IP
function naiveRateLimiter(req, res, next) {
  const ip = req.ip;
  const now = Date.now();
  const record = requestCounts.get(ip);

  // If no record exists or the window has expired, start a new window
  if (!record || now - record.windowStart > WINDOW_MS) {
    requestCounts.set(ip, { count: 1, windowStart: now });
    return next();
  }

  // If the client has exceeded the limit, reject the request
  if (record.count >= MAX_REQUESTS) {
    return res.status(429).json({ error: 'Too Many Requests', message: 'Rate limit exceeded. Try again later.' });
  }

  // Otherwise, increment the counter and allow the request
  record.count++;
  next();
}

app.use(naiveRateLimiter);

What does this code do?

requestCounts is a Map that stores each client's request count and window start time, keyed by IP address.

When a request arrives, the middleware checks: is this a new window (or no record)? If so, start fresh. Has the client hit the max? If so, return a 429 rejection. Otherwise, bump the count and let the request through.

app.use(naiveRateLimiter) applies this middleware to every incoming request before it reaches any route handler.

  • Save server.js.
  • In your terminal, stop the running server with Ctrl+C.
  • Restart the server by running:
npm start
  • You should see Server running on http://localhost:3000 with no errors. The rate limiter is now active.

Seeing a syntax error on startup?

  • Make sure the app.use(naiveRateLimiter) line is placed after the function definition and before the app.get() route definitions.
  • Check that the naiveRateLimiter function has its closing brace } before the app.use() call.

Help me fix a syntax error in my rate limiter middleware

✔️ Awesome, I've got everything!

Great. Make sure you've saved server.js and your server restarted without errors.

ⓧ I'd like to double check the full code

Your server.js should have these sections in order. Scroll up and confirm each one is present:

  • The require('express') import and app/PORT declarations (from Step 1).
  • The requestCounts Map, WINDOW_MS, and MAX_REQUESTS constants.
  • The naiveRateLimiter function definition.
  • app.use(naiveRateLimiter) before the route definitions.
  • The two app.get() routes and app.listen() at the bottom (from Step 1).
Test the limiter with rapid requests

The rate limiter is active. Time to see if it actually enforces the limit.

Before you run the next command, how many of 12 rapid requests do you think will get through before the limiter starts rejecting them?

  • Open a second terminal tab (keep the server running in the first one).
  • Fire 12 requests in rapid succession by running:
for i in $(seq 1 12); do curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/api/data; done

You should see the first 10 lines print 200 and the last 2 lines print 429. Your rate limiter is working.

What does this loop do?

The for loop sends 12 sequential HTTP requests. The -s flag silences progress output, -o /dev/null discards the response body, and -w "%{http_code}\n" prints only the HTTP status code for each request.

All 12 returning 200?

  • Make sure you restarted the server after saving your changes. The old process still has the original code without the rate limiter.
  • Check that app.use(naiveRateLimiter) appears before your app.get() routes. Middleware only applies to routes defined after it.

Help me debug why my rate limiter isn't blocking requests

Your rate limiter works perfectly. Ten requests get through, and any extra requests are cleanly rejected with a 429 status. It looks bulletproof. In the next step, you'll discover it has a critical flaw that makes it unsuitable for production.

Break the Naive Limiter

Your naive rate limiter passed every test in the previous step. It counted requests, respected the 10-per-minute cap, and returned 429s right on schedule.

Every fixed-window rate limiter shares a fundamental flaw at the window boundary. A client can exhaust their full quota at the very end of one window, then immediately fire another full quota the instant the window resets. In this step, you'll write a script that exploits this flaw and proves the limiter is broken for production use.

In this step, get ready to:
  • Expose the fixed-window boundary exploit with a burst test script.
  • Understand why in-memory state fails in multi-process deployments.
  • Remove the naive limiter to prepare for the Redis-backed implementation.
Create a burst test script

The boundary exploit works like this: your rate limiter resets its counter every 60 seconds. If someone sends 10 requests at t=59s and 10 more at t=61s, all 20 succeed within roughly 2 seconds of real time.

  • Create a new file called testBurst.js in your rate-limited-api folder by running:
touch testBurst.js
  • Confirm the file exists by running:
ls

You should see testBurst.js alongside server.js and package.json in the output.

  • Open testBurst.js in Cursor and add the following code:
// Burst test to demonstrate window boundary exploit
const BASE_URL = 'http://localhost:3000/api/data';

async function burstAtBoundary() {
  console.log('Sending 10 requests now (end of window)...');
  const first = Array.from({ length: 10 }, () => fetch(BASE_URL).then(r => r.status));
  const firstResults = await Promise.all(first);
  console.log('First batch:', firstResults);

  console.log('Waiting for window reset...');
  await new Promise(r => setTimeout(r, 61000));

  console.log('Sending 10 more requests immediately after reset...');
  const second = Array.from({ length: 10 }, () => fetch(BASE_URL).then(r => r.status));
  const secondResults = await Promise.all(second);
  console.log('Second batch:', secondResults);

  const total200 = [...firstResults, ...secondResults].filter(s => s === 200).length;
  console.log(`\nResult: ${total200} requests succeeded in ~2 windows (expected max 10, got ${total200})`);
}

burstAtBoundary();

What does this code do?

The script simulates a client exploiting the window boundary:

  • It fires 10 requests at once, filling the entire quota for the current window.
  • It waits 61 seconds for the window to reset completely.
  • It fires 10 more requests the instant the new window starts.
  • All 20 pass because each batch sits inside its own valid window. The total throughput is double the intended limit.
  • Save testBurst.js.

Before you run this, think about what you expect. The limiter allows 10 requests per 60 seconds. The script sends 10, waits for a reset, then sends 10 more. Will any requests be rejected?

  • Make sure your server is still running from the previous step. If you stopped it, restart it by running:
npm start

This test takes about 60 seconds

The script waits 61 seconds between batches for the window to reset. Keep your terminal open and let it run to completion.

  • Open a second terminal in Cursor (click the + icon in the terminal panel) and run the burst test:
node testBurst.js

After about 61 seconds, you'll see output like this:

Sending 10 requests now (end of window)...
First batch: [200, 200, 200, 200, 200, 200, 200, 200, 200, 200]
Waiting for window reset...
Sending 10 more requests immediately after reset...
Second batch: [200, 200, 200, 200, 200, 200, 200, 200, 200, 200]

Result: 20 requests succeeded in ~2 windows (expected max 10, got 20)

All 20 requests succeeded. Your "10 per minute" limiter just allowed 20 requests through by timing them at the window boundary.

Seeing some 429s in the first batch?

If you ran curl commands against the server recently, those requests consumed tokens in the current window. Stop the server with Ctrl+C, restart it with npm start, then immediately rerun node testBurst.js to get a fresh window.

Help me debug my burst test

Understand the multi-process flaw

The boundary exploit is not the only problem. Your rate limiter stores its state in a JavaScript Map that lives in process memory.

Why in-memory state breaks at scale

If you deployed two instances of this server behind a load balancer, each process would have its own Map with its own counters. A client could send 10 requests to instance A and 10 to instance B, getting 20 total. The instances have no shared view of the client's usage.

This is why production rate limiters store state in a shared data store like Redis. Every server instance reads and writes to the same counters, making the limit truly global.

Remove the naive limiter

The naive limiter has served its purpose. You've seen the boundary exploit and the multi-process flaw. Time to strip it out and prepare for the real implementation.

  • In server.js, find and delete the following lines (the Map, constants, the entire naiveRateLimiter function, and the app.use call):
const requestCounts = new Map();
const WINDOW_MS = 60 * 1000;
const MAX_REQUESTS = 10;

function naiveRateLimiter(req, res, next) {
  const ip = req.ip;
  const now = Date.now();
  const record = requestCounts.get(ip);

  if (!record || now - record.windowStart > WINDOW_MS) {
    requestCounts.set(ip, { count: 1, windowStart: now });
    return next();
  }

  if (record.count >= MAX_REQUESTS) {
    return res.status(429).json({ error: 'Too Many Requests', message: 'Rate limit exceeded. Try again later.' });
  }

  record.count++;
  next();
}

app.use(naiveRateLimiter);
  • Save server.js.
  • Stop the server with Ctrl+C and restart it by running:
npm start

You should see Server running on http://localhost:3000 with no errors. The API is now completely unprotected.

  • Confirm it responds without any rate limiting by running:
curl http://localhost:3000/api/data

You should see the JSON response with message and timestamp fields. No rate limiting at all.

Server won't start?

If you see a syntax error, make sure you only deleted the rate limiter code and did not accidentally remove the const express = require('express'); line or the route definitions. Double check against the full code below.

Help me fix my server.js after removing the rate limiter

✔️ Awesome, I've got everything!

Great. Double check you've saved both server.js and testBurst.js.

ⓧ I'd like to double check the full code

Here is what server.js should look like now (no rate limiter):

const express = require('express');

const app = express();
const PORT = 3000;

app.get('/api/data', (req, res) => {
  res.json({
    message: 'Here is your data!',
    timestamp: new Date().toISOString()
  });
});

app.get('/api/health', (req, res) => {
  res.json({ status: 'ok' });
});

app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

And here is testBurst.js (keep this file for reference):

// Burst test to demonstrate window boundary exploit
const BASE_URL = 'http://localhost:3000/api/data';

async function burstAtBoundary() {
  console.log('Sending 10 requests now (end of window)...');
  const first = Array.from({ length: 10 }, () => fetch(BASE_URL).then(r => r.status));
  const firstResults = await Promise.all(first);
  console.log('First batch:', firstResults);

  console.log('Waiting for window reset...');
  await new Promise(r => setTimeout(r, 61000));

  console.log('Sending 10 more requests immediately after reset...');
  const second = Array.from({ length: 10 }, () => fetch(BASE_URL).then(r => r.status));
  const secondResults = await Promise.all(second);
  console.log('Second batch:', secondResults);

  const total200 = [...firstResults, ...secondResults].filter(s => s === 200).length;
  console.log(`\nResult: ${total200} requests succeeded in ~2 windows (expected max 10, got ${total200})`);
}

burstAtBoundary();

Your API is now wide open with no protection at all. In the next step, you'll build the real rate limiter using the token bucket algorithm backed by Redis. It eliminates both flaws: no boundary exploit and shared state across all server instances.

Implement the Token Bucket with Redis

You proved the naive limiter breaks at window boundaries and fails across multiple processes. Now you'll replace it with the algorithm that Stripe, GitHub, and AWS use in production: the token bucket.

The token bucket solves both flaws. Tokens refill continuously (no boundary to exploit), and storing state in Redis means every server instance shares the same bucket. A Lua script inside Redis makes the read-compute-write cycle atomic so two simultaneous requests can never both grab the last token.

In this step, get ready to:
  • Create a Redis-backed token bucket rate limiter with an atomic Lua script.
  • Wire the limiter into your Express API as middleware.
  • Run a burst test proving exactly 10 of 15 requests succeed.
Create the token bucket Lua script

The core of a token bucket lives in a single Lua script that Redis executes atomically. You'll store it in a new file alongside your server.

  • Create a new file called rateLimiter.js in your rate-limited-api folder.
  • Add the Redis connection and the first section of the Lua script by pasting this code:
const Redis = require('ioredis');
const redis = new Redis();

const TOKEN_BUCKET_SCRIPT = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refillRate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])

local bucket = redis.call('hmget', key, 'tokens', 'lastRefill')
local tokens = tonumber(bucket[1])
local lastRefill = tonumber(bucket[2])

if tokens == nil then
  tokens = capacity
  lastRefill = now
end

local elapsed = now - lastRefill
local tokensToAdd = elapsed * refillRate
tokens = math.min(capacity, tokens + tokensToAdd)
lastRefill = now

What does this code do?

This is the first half of the Lua script that runs inside Redis. It receives the bucket key, capacity, refill rate, and current timestamp as arguments.

  • hmget fetches the current token count and last refill time from a Redis hash.
  • If the key doesn't exist yet (first request), it initializes with a full bucket.
  • It calculates how many tokens to add based on elapsed time, capping at capacity. This continuous refill is what eliminates the boundary exploit.
  • Add the decision logic directly below (still inside the same template literal) by pasting this code:
if tokens >= 1 then
  tokens = tokens - 1
  redis.call('hmset', key, 'tokens', tokens, 'lastRefill', lastRefill)
  redis.call('expire', key, math.ceil(capacity / refillRate) * 2)
  return {1, math.floor(tokens), capacity}
else
  redis.call('hmset', key, 'tokens', tokens, 'lastRefill', lastRefill)
  redis.call('expire', key, math.ceil(capacity / refillRate) * 2)
  local retryAfter = math.ceil((1 - tokens) / refillRate)
  return {0, 0, capacity, retryAfter}
end
`;

What does the decision logic do?

This half decides whether to allow or reject the request.

  • If at least 1 token remains, it consumes one and returns {1, remaining, capacity} (allowed).
  • If the bucket is empty, it returns {0, 0, capacity, retryAfter} (rejected) with the wait time until the next token.
  • expire sets a TTL so Redis auto-cleans idle buckets. No stale keys build up over time.
  • Because the entire script runs atomically, two simultaneous requests can never both see the last token and both consume it.
  • Now add the JavaScript middleware function that calls this script. Paste this below the closing backtick:
function createRateLimiter({ capacity = 10, refillRate = 2 } = {}) {
  return async (req, res, next) => {
    // Build a unique key per client IP
    const key = `ratelimit:${req.ip}`;
    const now = Date.now() / 1000;

    try {
      // Execute the Lua script atomically in Redis
      const result = await redis.eval(
        TOKEN_BUCKET_SCRIPT,
        1,
        key,
        capacity,
        refillRate,
        now
      );

      const allowed = result[0] === 1;
      const remaining = result[1];
      const limit = result[2];

      if (!allowed) {
        const retryAfter = result[3] || Math.ceil(1 / refillRate);
        return res.status(429).json({
          error: 'Too Many Requests',
          message: `Rate limit exceeded. Try again in ${retryAfter} second(s).`,
          retryAfter
        });
      }

      next();
    } catch (err) {
      // Fail open: if Redis is down, allow the request
      console.error('Rate limiter error:', err.message);
      next();
    }
  };
}

module.exports = { createRateLimiter };

What does createRateLimiter do?

This factory function returns Express middleware. Each incoming request triggers the Lua script via redis.eval().

  • The key is built from the client's IP address, giving each client their own bucket.
  • If the script returns "not allowed," the middleware sends a 429 response with a retry time.
  • The catch block calls next() (fail-open). If Redis goes down, requests still get through rather than crashing the API.
  • Save rateLimiter.js.
  • Confirm the module loads without errors by running:
node -e "require('./rateLimiter')"

If you see no output and the command exits cleanly, the file is valid.

Seeing a syntax error?

  • Check that the closing backtick and semicolon (`;) appear on their own line after end. A missing backtick means the template literal never closes.
  • Make sure ioredis is installed. If you see "Cannot find module 'ioredis'", run npm install ioredis@5.11.1 again.
  • Confirm Redis is running with redis-cli ping. You should see PONG.

Why isn't my rateLimiter.js loading?

✔️ Awesome, I've got everything!

Great. Double check you've saved rateLimiter.js and move on.

ⓧ I'd like to double check the full code

Your rateLimiter.js has three sections pasted in order. Scroll up and confirm:

  • Section 1: const Redis = require('ioredis') through lastRefill = now (the refill calculation).
  • Section 2: if tokens >= 1 then through the closing `; (the decision logic).
  • Section 3: function createRateLimiter through module.exports = { createRateLimiter }; (the middleware).
Wire up the middleware in server.js

The rate limiter module is ready. Now you need to import it and apply it as middleware so every request passes through the token bucket before reaching your routes.

  • Open server.js and replace its entire contents with the following:
const express = require('express');
const { createRateLimiter } = require('./rateLimiter');

const app = express();
const PORT = 3000;

// Apply token bucket rate limiter to all routes
app.use(createRateLimiter({ capacity: 10, refillRate: 2 }));

app.get('/api/data', (req, res) => {
  res.json({
    message: 'Here is your data!',
    timestamp: new Date().toISOString()
  });
});

app.get('/api/health', (req, res) => {
  res.json({ status: 'ok' });
});

app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});

What changed?

  • Line 2 imports createRateLimiter from your new module.
  • app.use(createRateLimiter({ capacity: 10, refillRate: 2 })) applies the limiter globally. Every request must pass through it.
  • capacity: 10 means a full bucket holds 10 tokens (the max burst size).
  • refillRate: 2 means 2 tokens are added per second (the sustained rate).
  • Save server.js.
  • If your server is still running from before, stop it with Ctrl+C in the terminal.
  • Restart the server by running:
npm start
  • Test that requests still work by running:
curl http://localhost:3000/api/data

You should see the familiar JSON response with a message and timestamp. The rate limiter is silently working behind the scenes.

Seeing "Cannot find module './rateLimiter'"?

  • Make sure rateLimiter.js is in the same folder as server.js. Check with ls and confirm both files appear.
  • Check the filename spelling. It must be exactly rateLimiter.js (capital L).

Why can't my server find the rateLimiter module?

✔️ Awesome, I've got everything!

Great. Confirm server.js is saved 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(createRateLimiter({ capacity: 10, refillRate: 2 }));

app.get('/api/data', (req, res) => {
  res.json({
    message: 'Here is your data!',
    timestamp: new Date().toISOString()
  });
});

app.get('/api/health', (req, res) => {
  res.json({ status: 'ok' });
});

app.listen(PORT, () => {
  console.log(`Server running on http://localhost:${PORT}`);
});
Run the burst test against the real limiter

Your old testBurst.js was designed to exploit window boundaries. Now you need a simpler test: fire 15 requests simultaneously and confirm the bucket holds exactly 10.

  • Open testBurst.js and replace its entire contents with:
const BASE_URL = 'http://localhost:3000/api/data';

async function burstTest(count) {
  console.log(`\nSending ${count} requests as fast as possible...\n`);

  const results = { success: 0, limited: 0 };

  // Fire all requests simultaneously
  const requests = Array.from({ length: count }, (_, i) =>
    fetch(BASE_URL)
      .then(async (res) => {
        if (res.status === 200) {
          results.success++;
          console.log(`Request ${i + 1}: 200 OK`);
        } else {
          results.limited++;
          console.log(`Request ${i + 1}: 429 RATE LIMITED`);
        }
      })
      .catch((err) => console.error(`Request ${i + 1}: ERROR - ${err.message}`))
  );

  await Promise.all(requests);

  console.log(`\n--- Results ---`);
  console.log(`Successful: ${results.success}`);
  console.log(`Rate limited: ${results.limited}`);
  console.log(`Total: ${count}`);
}

burstTest(15);

What does this test do?

  • It fires 15 HTTP requests using Promise.all so they all hit the server at virtually the same instant.
  • Each response is logged as either 200 OK or 429 RATE LIMITED.
  • The summary at the end tallies successes and rejections.
  • Save testBurst.js.
  • First, flush any leftover token data in Redis so you start fresh. Run:
redis-cli FLUSHALL

Before you run the burst test, predict: out of 15 simultaneous requests, how many do you think will succeed?

  • Run the burst test in a new terminal (keep your server running):
node testBurst.js

You should see exactly 10 requests return 200 OK and 5 return 429 RATE LIMITED. The bucket started full (capacity 10), so the first 10 requests each consumed a token. The remaining 5 found an empty bucket and were rejected.

Seeing more or fewer than 10 successes?

  • If all 15 succeed, your server might still be running the old server.js without the rate limiter. Stop it with Ctrl+C and restart with npm start.
  • If you see fewer than 10 successes, you might have leftover keys from a previous test. Run redis-cli FLUSHALL and try again.
  • If you see connection errors, make sure your server is running in another terminal before executing the test.

Why am I not seeing exactly 10 successes in my burst test?

✔️ Awesome, I've got everything!

Great. Confirm testBurst.js is saved and showing the right results.

ⓧ I'd like to double check the full code

const BASE_URL = 'http://localhost:3000/api/data';

async function burstTest(count) {
  console.log(`\nSending ${count} requests as fast as possible...\n`);

  const results = { success: 0, limited: 0 };

  const requests = Array.from({ length: count }, (_, i) =>
    fetch(BASE_URL)
      .then(async (res) => {
        if (res.status === 200) {
          results.success++;
          console.log(`Request ${i + 1}: 200 OK`);
        } else {
          results.limited++;
          console.log(`Request ${i + 1}: 429 RATE LIMITED`);
        }
      })
      .catch((err) => console.error(`Request ${i + 1}: ERROR - ${err.message}`))
  );

  await Promise.all(requests);

  console.log(`\n--- Results ---`);
  console.log(`Successful: ${results.success}`);
  console.log(`Rate limited: ${results.limited}`);
  console.log(`Total: ${count}`);
}

burstTest(15);

Your API is now protected by a production-grade token bucket. Next up, you'll add rate limit headers so clients know exactly how many requests they have left and when their budget resets.

Add Rate Limit Headers

Your token bucket rate limiter works. It correctly allows 10 requests and rejects the rest with a 429 status.

But right now, clients have no way to know how many requests they have left until they hit the wall. Without rate limit headers, every client becomes a blind retry machine, hammering the API until it gets rejected. In this step, you'll add standard HTTP headers that tell clients their remaining budget and when to back off.

In this step, get ready to:
  • Add RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset headers to every response.
  • Add a Retry-After header to 429 responses so clients know exactly how long to wait.
  • Update the burst test to display header values and confirm the full picture.
Add standard rate limit headers to every response

Well-designed APIs communicate rate limit state on every response. This lets clients track their remaining budget and throttle themselves before they get rejected.

  • Stop your running server by pressing Ctrl+C in the terminal.
  • Open rateLimiter.js in Cursor.
  • Find the line const limit = result[2]; inside the try block.
  • Add the following code directly below that line:
      const resetTime = Math.ceil(now + (limit - remaining) / refillRate);

      res.set('RateLimit-Limit', String(limit));
      res.set('RateLimit-Remaining', String(remaining));
      res.set('RateLimit-Reset', String(resetTime));

What do these headers mean?

  • RateLimit-Limit tells the client the bucket capacity (maximum requests allowed in a burst).
  • RateLimit-Remaining tells the client how many tokens are left right now.
  • RateLimit-Reset is a Unix timestamp for when the bucket will be completely full again. Clients can use this to plan ahead.
  • resetTime is calculated from how many tokens are missing and how fast they refill.
  • Save rateLimiter.js.
  • Restart the server by running:
npm start
  • In a second terminal tab, test the headers by running:
curl -i http://localhost:3000/api/data

You should see RateLimit-Limit: 10, RateLimit-Remaining: 9, and RateLimit-Reset with a Unix timestamp in the response headers.

Not seeing the rate limit headers?

  • Make sure you saved rateLimiter.js and restarted the server after saving.
  • Check that the res.set() calls are placed after const limit = result[2]; but before the if (!allowed) check. They need to run for every request, not just rejected ones.
  • Make sure you're using the -i flag with curl. Without it, headers are hidden.

Why aren't my rate limit headers showing up in the curl response?

Enhance the 429 response with Retry-After

The three rate limit headers appear on every response. But when a client is actually rate limited, they need one more piece of information: exactly how many seconds to wait before retrying.

  • In rateLimiter.js, find the if (!allowed) block.
  • Find this line:
        const retryAfter = result[3] || Math.ceil(1 / refillRate);
        return res.status(429).json({
  • Add a res.set('Retry-After', String(retryAfter)) call between those two lines, so the block now looks like this:
        const retryAfter = result[3] || Math.ceil(1 / refillRate);
        res.set('Retry-After', String(retryAfter));
        return res.status(429).json({

What does Retry-After do?

The Retry-After header tells well-behaved clients exactly how many seconds to wait before sending another request. Combined with the 429 JSON body (which already includes error, message, and retryAfter fields), clients get everything they need to back off gracefully.

  • Save rateLimiter.js.
  • Stop the server with Ctrl+C and restart it by running:
npm start
  • Exhaust the bucket and see the Retry-After header by running this loop:
for i in $(seq 1 11); do curl -s -o /dev/null -w "%{http_code} " http://localhost:3000/api/data; done && echo ""

You should see ten 200 codes followed by a 429. Now send one more request with full headers visible:

curl -i http://localhost:3000/api/data

The response should show a 429 status with Retry-After: 1 (or a small number) in the headers, plus the JSON error body.

Getting a 200 instead of 429?

  • Tokens refill at 2 per second. If you ran the loop slowly, the bucket may have recovered. Run the loop and the final curl command quickly, one after the other.
  • If you restarted the server between the loop and the final curl, Redis still holds the old bucket state, but double-check that the server is running on port 3000.

Why am I not seeing a 429 response when I exhaust the token bucket?

✔️ 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

Your rateLimiter.js is over 30 lines, so scroll through and confirm these sections are present in order:

  • The Redis import and client creation at the top.
  • The TOKEN_BUCKET_SCRIPT Lua string (unchanged from the previous step).
  • Inside createRateLimiter: after const limit = result[2], the resetTime calculation followed by three res.set() calls for RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset.
  • Inside the if (!allowed) block: res.set('Retry-After', String(retryAfter)) before the return res.status(429) line.
  • The module.exports at the bottom.
Update the burst test to display header values

Your burst test currently logs bare status codes. Now that the API returns headers, you can make the test output much more informative.

  • Open testBurst.js in Cursor.
  • Find this line inside the success branch:
          console.log(`Request ${i + 1}: 200 OK`);
  • Replace it with this line that reads the remaining tokens from the header:
          console.log(`Request ${i + 1}: 200 OK (${res.headers.get('ratelimit-remaining')} remaining)`);
  • Now find the line in the rate-limited branch:
          console.log(`Request ${i + 1}: 429 RATE LIMITED`);
  • Replace it with this line that reads the retry-after value:
          console.log(`Request ${i + 1}: 429 RATE LIMITED (retry after ${res.headers.get('retry-after')}s)`);

What's happening here?

The res.headers.get('ratelimit-remaining') call reads the header your middleware just set. Notice the header name is lowercase in the fetch API. This is standard behavior for the Fetch Headers interface.

  • Save testBurst.js.

✔️ Awesome, I've got everything!

Great. Make sure you've saved testBurst.js before running the test.

ⓧ I'd like to double check the full code

const BASE_URL = 'http://localhost:3000/api/data';

async function burstTest(count) {
  console.log(`\nSending ${count} requests as fast as possible...\n`);

  const results = { success: 0, limited: 0 };

  const requests = Array.from({ length: count }, (_, i) =>
    fetch(BASE_URL)
      .then(async (res) => {
        if (res.status === 200) {
          results.success++;
          console.log(`Request ${i + 1}: 200 OK (${res.headers.get('ratelimit-remaining')} remaining)`);
        } else {
          results.limited++;
          console.log(`Request ${i + 1}: 429 RATE LIMITED (retry after ${res.headers.get('retry-after')}s)`);
        }
      })
      .catch((err) => console.error(`Request ${i + 1}: ERROR - ${err.message}`))
  );

  await Promise.all(requests);

  console.log(`\n--- Results ---`);
  console.log(`Successful: ${results.success}`);
  console.log(`Rate limited: ${results.limited}`);
  console.log(`Total: ${count}`);
}

burstTest(15);

Before you run the burst test, think: out of 15 simultaneous requests, how many do you expect to succeed and how many to be rejected? What will the remaining count look like?

  • Make sure your server is still running. If not, start it again with npm start.
  • Wait a few seconds for the bucket to refill, then run the burst test:
node testBurst.js

You should see 10 requests succeed with a decreasing "remaining" count, and 5 requests rejected with a "retry after" value showing how long to wait.

Why might the order look scrambled?

The requests are fired simultaneously with Promise.all, so they resolve in whatever order the server processes them. Request 3 might log before Request 1. The important thing is the totals: 10 successes and 5 limited.

Seeing all 15 succeed?

  • If you waited more than 5 seconds between running the loop earlier and running the burst test, the bucket refilled. Wait a couple seconds for a full refill, then run node testBurst.js immediately after the server starts.
  • Check that you restarted the server after editing rateLimiter.js. If the old middleware is still running, headers may not appear.

My burst test shows all 15 requests succeeding. Help me figure out why the rate limiter isn't rejecting requests.

Your rate limiter now gives clients everything they need to behave well: a clear budget on every response and precise wait instructions when the budget runs out. Next up, you'll extend this into a per-endpoint tiered system in the Secret Mission.

Secret mission

Per-Endpoint Tiered Limits

Production APIs don't apply the same limit to all endpoints. Auth routes need strict limits to prevent brute force attacks, while read-only routes can be more generous. Can you add per-endpoint tiered limits to your rate limiter?

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:

  • Redis running as a background Homebrew service.
  • The rate-limited-api project folder on your Desktop.

Keep everything running

No action needed. Choose this if you're still actively building or want to experiment further with your rate limiter.

  • Redis continues running in the background. It costs nothing and uses minimal memory.
  • Your project files remain in ~/Desktop/rate-limited-api.

Pause - I'll come back to this later

Stop the Redis background service to free up memory. Your project files stay on disk so you can pick up where you left off.

  • Stop the Redis service by running:
brew services stop redis
  • When you're ready to resume, start it again with brew services start redis.

Delete - I don't want to use this again

Remove all project resources and start fresh. This stops Redis, uninstalls it, and deletes your project files.

  • Stop and uninstall Redis by running:
brew services stop redis
brew uninstall redis
  • Delete the project folder by running:
rm -rf ~/Desktop/rate-limited-api
  • Confirm the folder is gone by running:
ls ~/Desktop/rate-limited-api
  • You should see a "No such file or directory" message, confirming everything has been removed.

That's a Wrap!

That's a Wrap!

Nice work! You just built a production-grade rate limiter from scratch using the same algorithm that powers Stripe, GitHub, and AWS.

You've learned how to:

  • Build a Redis-backed token bucket rate limiter that uses atomic Lua scripting to prevent race conditions across concurrent requests.
  • Expose and exploit the fixed-window boundary flaw to understand why the industry moved to token bucket for burst-tolerant rate limiting.
  • Communicate rate limit state to clients via standard headers (RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, Retry-After) so well-behaved clients can self-throttle before hitting the wall.
  • Secret Mission: Implemented per-endpoint tiered limits with strict protection on auth routes and generous limits on read routes.

Ready to quiz yourself?