Distributed Rate Limiter with Redis

Build a rate-limiting API in TypeScript using Redis and Lua scripting.

Introduction

30 Second Summary

Every time you scroll through a feed, search for a product, or tap refresh on your weather app, your phone fires requests at a server. If one user sends thousands of those requests per second, the server slows to a crawl for everyone.

In this project, you will build a distributed rate limiter in TypeScript and Redis. You'll implement two rate-limiting algorithms, watch one break under real traffic, then fix it with an atomic Lua script.

What You'll Build

You fire rapid requests at your Express API, watch the response headers count your remaining quota down to zero, and then see 429 Too Many Requests slam the door shut.

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

  • An API that enforces per-client request quotas using Redis, returning real-time feedback through standard HTTP headers like X-RateLimit-Remaining and X-RateLimit-Reset.
  • Hands-on proof that the fixed-window algorithm breaks at window boundaries. You'll fix it with a sliding-window counter powered by an atomic Redis Lua script that eliminates race conditions.
  • An in-memory fallback that keeps rate limiting alive when Redis goes down, so your API degrades gracefully instead of failing completely.
  • An optional Secret Mission to push your skills even further.

Do I need experience with Redis or system design?

No. This project walks you through everything from scratch, including setting up Redis in a Docker container and writing your first Lua script.

Before We Start

Before we dive in, let's lock in what you're about to build and why it matters.

Set Up the Project and Redis

Before you can limit any traffic, you need two things running: a web server to handle incoming requests, and a database to track how many requests each client has sent.

In this step, you'll scaffold a TypeScript project with Express, spin up a Redis container in Docker, and wire them together with a basic server.

In this step, get ready to:
  • Initialize a TypeScript project and install dependencies.
  • Start a Redis 7 container in Docker.
  • Create an Express server that connects to Redis and serves a ping endpoint.
Initialize the project and install dependencies

You need a project folder with a Node.js package configuration, TypeScript settings, and the libraries your rate limiter will use.

  • 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 and create the project folder by running these commands:
cd ~/Desktop
mkdir rate-limiter
cd rate-limiter
  • Initialize a new npm project by running:
npm init -y

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

  • Install the project dependencies by running these two commands:
npm install --save-exact express@5.0.1 ioredis@5.4.2
npm install --save-exact -D tsx@4.19.2 typescript@5.7.2 @types/express@5.0.0 @types/node@22.10.2

What are these packages?

  • express is the web framework that handles HTTP requests and routing.
  • ioredis is a Redis client for Node.js that lets your server talk to Redis.
  • tsx runs TypeScript files directly without a separate compile step.
  • typescript, @types/express, and @types/node provide type checking and editor autocomplete for your code.

Seeing install errors?

Make sure you are inside the rate-limiter folder before running npm install. Run pwd to confirm your current directory ends with rate-limiter.

If you see permission errors, try prefixing the command with sudo (macOS/Linux) or running your terminal as Administrator (Windows).

Help me fix npm install errors in my rate limiter project.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Kiro and press Enter to open it.
  • In Kiro, click File in the top menu bar.
  • Click Open Folder, then navigate to Desktop and select the rate-limiter folder.
  • Click Open.

Your Kiro sidebar should now show the rate-limiter project with node_modules, package.json, and package-lock.json listed in the file tree.

The package.json file that npm init generated has a default test script. You need to replace it with scripts that run your server and tests.

  • In the Kiro sidebar, click package.json to open it.
  • Find the scripts section. It looks like this:
"scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  • Replace that entire scripts block with the following:
"scripts": {
    "dev": "npx tsx src/index.ts",
    "test:burst": "npx tsx src/tests/burstTest.ts",
    "test:token": "npx tsx src/tests/tokenBucketTest.ts"
  },

What do these scripts do?

  • dev starts your Express server.
  • test:burst and test:token run test scripts you will create in later steps.
  • Save package.json by pressing Cmd+S (macOS) or Ctrl+S (Windows).

Next, you need a TypeScript configuration file that tells the compiler how to process your code.

  • Right-click in the Kiro sidebar file tree and select New File.
  • Name the file tsconfig.json and press Enter.
  • Paste the following configuration into tsconfig.json:
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true
  },
  "include": ["src/**/*"]
}

What does this config do?

This tells TypeScript to compile your code using modern JavaScript features (ES2022) and to look for source files inside the src folder. The strict flag enables full type checking, which catches bugs before your code runs.

The esModuleInterop setting lets you use import express from "express" syntax instead of the more verbose require() style.

  • Save tsconfig.json.
  • Confirm your project files are in place by opening Kiro's integrated terminal. Click Terminal in the top menu bar, then New Terminal.
  • Run this command in the terminal:
ls

You should see node_modules, package.json, package-lock.json, and tsconfig.json in the output.

✔️ Awesome, I've got everything!

Great. Make sure both package.json and tsconfig.json are saved before moving on.

ⓧ I'd like to double check the full code

Here is the full tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "lib": ["ES2022"],
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "declaration": true
  },
  "include": ["src/**/*"]
}

And here is the full package.json. Your version may have extra fields like main or keywords from npm init. The important parts are the scripts, dependencies, and devDependencies sections:

{
  "name": "rate-limiter",
  "version": "1.0.0",
  "scripts": {
    "dev": "npx tsx src/index.ts",
    "test:burst": "npx tsx src/tests/burstTest.ts",
    "test:token": "npx tsx src/tests/tokenBucketTest.ts"
  },
  "dependencies": {
    "express": "5.0.1",
    "ioredis": "5.4.2"
  },
  "devDependencies": {
    "@types/express": "5.0.0",
    "@types/node": "22.10.2",
    "tsx": "4.19.2",
    "typescript": "5.7.2"
  }
}
Start Redis in Docker

Your rate limiter needs a place to store request counts that all server instances can share. Redis is an in-memory database that is fast enough to check on every single incoming request.

  • Start a Redis 7 container by running this command in Kiro's integrated terminal:
docker run --name redis-rate-limiter -d -p 6379:6379 redis:7

What does this command do?

  • --name redis-rate-limiter gives the container a friendly name so you can stop or restart it later.
  • -d runs the container in the background so your terminal stays free.
  • -p 6379:6379 maps the container's Redis port to your machine so your server can connect to it at 127.0.0.1:6379.
  • redis:7 pulls the official Redis 7 image, which supports the Lua scripting you will use for atomic rate limiting.
  • Verify the container is running:
docker ps

You should see a row with redis-rate-limiter in the NAMES column and Up in the STATUS column.

Container not starting?

If you see a message about the name redis-rate-limiter already being in use, a previous container with that name exists. Remove it first by running docker rm -f redis-rate-limiter, then re-run the docker run command.

If Docker itself is not running, open Docker Desktop from your applications and wait for the engine to start before trying again.

Help me get my Redis Docker container running.

Create the Express server

With Redis running, you can now create your server entry point. This file connects Express to Redis and exposes a single endpoint you will protect with rate limiting in the next step.

  • Create the src directory by running this command in Kiro's terminal:
mkdir src
  • In the Kiro sidebar, right-click the new src folder and select New File.
  • Name the file index.ts and press Enter.
  • Paste the following code into src/index.ts:
import express from "express";
import Redis from "ioredis";

// Create the Express app and connect to Redis
const app = express();
const redis = new Redis({ host: "127.0.0.1", port: 6379 });

// Log when Redis connects or errors
redis.on("connect", () => {
  console.log("Connected to Redis");
});

redis.on("error", (err) => {
  console.error("Redis connection error:", err.message);
});

// A simple endpoint to test that the server is working
app.get("/api/ping", (_req, res) => {
  res.json({ message: "pong" });
});

// Start the server on port 3000
const PORT = 3000;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

What does this code do?

  • new Redis({ host: "127.0.0.1", port: 6379 }) connects to the Redis container you just started. The ioredis client handles reconnection automatically if Redis restarts.
  • The connect and error event listeners log whether the connection succeeds or fails, so you always know if your database is reachable.
  • GET /api/ping is a health-check endpoint that returns { message: "pong" }. In the next step, you will put a rate limiter in front of it.
  • Save src/index.ts.

Before you run the server, what do you expect to see in the terminal output?

  • Start the server by running:
npx tsx src/index.ts

You should see two lines printed in the terminal:

Connected to Redis
Server running on port 3000

That confirms Express is listening and ioredis successfully connected to your Redis container.

  • The server is now occupying your terminal. Click the + button in Kiro's terminal panel to open a second terminal.
  • Test the ping endpoint by running:
curl http://localhost:3000/api/ping

You should see the JSON response:

{"message":"pong"}

Not seeing the expected output?

If you see a Redis connection error, make sure your Docker container is still running. Run docker ps in a terminal to check. If it is not listed, restart it with docker start redis-rate-limiter.

If curl returns a connection refused error, confirm the server is running in your first terminal tab. You should see the Server running on port 3000 message there.

Help me troubleshoot my Express and Redis server.

  • Stop the server in your first terminal by pressing Ctrl+C.

✔️ Awesome, I've got everything!

Great. Double check that src/index.ts is saved before moving on.

ⓧ I'd like to double check the full code

Here is the complete src/index.ts:

import express from "express";
import Redis from "ioredis";

const app = express();
const redis = new Redis({ host: "127.0.0.1", port: 6379 });

redis.on("connect", () => {
  console.log("Connected to Redis");
});

redis.on("error", (err) => {
  console.error("Redis connection error:", err.message);
});

app.get("/api/ping", (_req, res) => {
  res.json({ message: "pong" });
});

const PORT = 3000;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

Your TypeScript project is scaffolded, Redis is running, and your Express server can talk to both. Next up, you will build a rate limiter that actually counts requests and blocks clients who send too many.

Build a Fixed-Window Rate Limiter

Your Express server is up and connected to Redis. Right now, though, anyone can hit /api/ping as many times as they want with zero consequences.

A rate limiter fixes that by counting how many requests a client has made within a time window and rejecting anything over the limit. In this step, you will build the simplest version of this pattern: a fixed-window counter that tracks requests in Redis and returns 429 Too Many Requests once a client exceeds the quota.

In this step, get ready to:
  • Create a fixed-window limiter function that counts requests per client in Redis.
  • Build an Express middleware that enforces rate limits with standard HTTP headers.
  • Wire the middleware onto your /api/ping endpoint and watch it reject the 11th request.
Create the fixed-window limiter function

The fixed-window algorithm works by dividing time into fixed-length buckets. Each bucket gets its own counter in Redis. When a request arrives, the counter increments. If it exceeds the maximum, the request is denied.

  • Create the src/limiters directory and the fixedWindow.ts file inside it by running these commands:
mkdir -p src/limiters
touch src/limiters/fixedWindow.ts
  • Confirm the file was created by running:
ls src/limiters

You should see fixedWindow.ts listed in the output.

  • Open src/limiters/fixedWindow.ts in Kiro and paste the following code:
import Redis from "ioredis";

// Shape of every rate-limit check result
export interface RateLimitResult {
  allowed: boolean;
  remaining: number;
  resetAt: number;
}

// Count requests per key within a fixed time window
export async function checkFixedWindow(
  redis: Redis,
  key: string,
  windowMs: number,
  max: number
): Promise<RateLimitResult> {
  const windowSec = Math.ceil(windowMs / 1000);
  const windowKey = `rl:fixed:${key}:${Math.floor(Date.now() / windowMs)}`;

  // Atomically increment the counter for this window
  const count = await redis.incr(windowKey);

  // Set expiry only on the first request in this window
  if (count === 1) {
    await redis.expire(windowKey, windowSec);
  }

  const allowed = count <= max;
  const remaining = Math.max(0, max - count);
  const resetAt = (Math.floor(Date.now() / windowMs) + 1) * windowMs;

  return { allowed, remaining, resetAt };
}

What does this code do?

The RateLimitResult interface defines the shape that every limiter returns: whether the request is allowed, how many requests are remaining, and when the window resets via resetAt.

The windowKey is a Redis key that includes the current time window number. Every request in the same 60-second window increments the same key. When the window rolls over, Math.floor(Date.now() / windowMs) produces a new number, so a fresh key starts at zero.

The redis.incr() call is atomic. Redis guarantees that even if two requests arrive at the exact same instant, each gets its own count. The redis.expire() call only fires when count === 1, meaning it is the first request in this window. The TTL ensures old keys are cleaned up automatically.

  • Save src/limiters/fixedWindow.ts.

Seeing a red underline on the import?

Make sure you installed ioredis in Step 1. If the import shows an error, run npm install in your terminal to re-install all dependencies.

Help me fix my ioredis import error.

Build the rate-limit middleware

The limiter function checks counts, but it does not know about HTTP. You need a middleware that sits between Express and your route handler, calling the limiter on every request and setting the right response headers.

  • Create the src/middleware directory and the rateLimit.ts file by running:
mkdir -p src/middleware
touch src/middleware/rateLimit.ts
  • Confirm the file exists by running:
ls src/middleware

You should see rateLimit.ts in the output.

  • Open src/middleware/rateLimit.ts in Kiro and paste this code:
import { Request, Response, NextFunction } from "express";
import Redis from "ioredis";
import { checkFixedWindow } from "../limiters/fixedWindow";

// Configuration for the rate limiter
interface RateLimitOptions {
  redis: Redis;
  windowMs: number;
  max: number;
  algorithm: "fixed" | "sliding";
}

export function createRateLimitMiddleware(options: RateLimitOptions) {
  const { redis, windowMs, max, algorithm } = options;

  return async (req: Request, res: Response, next: NextFunction) => {
    const clientKey = req.ip || "unknown";
    const result = await checkFixedWindow(redis, clientKey, windowMs, max);

    // Set standard rate-limit headers on every response
    res.set("X-RateLimit-Limit", String(max));
    res.set("X-RateLimit-Remaining", String(result.remaining));
    res.set("X-RateLimit-Reset", String(Math.ceil(result.resetAt / 1000)));

    if (!result.allowed) {
      const retryAfter = Math.max(1, Math.ceil((result.resetAt - Date.now()) / 1000));
      res.set("Retry-After", String(retryAfter));
      res.status(429).json({ error: "Too Many Requests", retryAfter });
      return;
    }

    next();
  };
}

What does this middleware do?

The createRateLimitMiddleware factory returns an Express middleware function. Every incoming request passes through it before reaching your route handler.

The middleware identifies clients by req.ip, which is the remote IP address of the request. When you test locally, all requests come from the same IP, so they share one counter.

Three headers go on every response: X-RateLimit-Limit (the max allowed), X-RateLimit-Remaining (how many are left), and X-RateLimit-Reset (when the window resets, as a Unix timestamp in seconds). If the limit is exceeded, the middleware returns 429 with a Retry-After header telling the client how long to wait.

The algorithm field in the options is typed as "fixed" | "sliding". Right now only "fixed" is wired up. You will add the sliding-window path in a later step.

  • Save src/middleware/rateLimit.ts.

Seeing a type error on req.ip?

Make sure you have @types/express installed. The req.ip property can return undefined in some environments, which is why the code falls back to "unknown".

Help me fix this TypeScript error on req.ip.

Wire the middleware onto your endpoint

The limiter function and middleware are ready. Now you need to plug the middleware into your Express server so every request to /api/ping runs through the rate limiter before reaching the route handler.

  • Switch back to src/index.ts in Kiro.
  • Replace the entire contents of the file with this updated code:
import express from "express";
import Redis from "ioredis";
import { createRateLimitMiddleware } from "./middleware/rateLimit";

const app = express();
const redis = new Redis({ host: "127.0.0.1", port: 6379 });

redis.on("connect", () => {
  console.log("Connected to Redis");
});

redis.on("error", (err) => {
  console.error("Redis connection error:", err.message);
});

// Create a fixed-window rate limiter: 10 requests per 60 seconds
const fixedWindowLimiter = createRateLimitMiddleware({
  redis,
  windowMs: 60000,
  max: 10,
  algorithm: "fixed",
});

// Apply the rate limiter to the ping endpoint
app.get("/api/ping", fixedWindowLimiter, (_req, res) => {
  res.json({ message: "pong" });
});

const PORT = 3000;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

What changed in index.ts?

You added an import for createRateLimitMiddleware and created a fixedWindowLimiter configured for 10 requests per 60-second window.

The key change is on the route line. Previously it was app.get("/api/ping", (_req, res) => .... Now fixedWindowLimiter sits between the path and the handler, so Express runs the middleware first. If the middleware calls next(), the handler runs. If it returns 429, the handler never executes.

  • Save src/index.ts.
  • Stop your running server with Ctrl+C in the terminal, then restart it by running:
npx tsx src/index.ts

You should see "Connected to Redis" and "Server running on port 3000" in the terminal again.

  • Open a second terminal tab. Send a single request with headers visible by running:
curl -i http://localhost:3000/api/ping

You should see the response body along with the rate-limit headers: X-RateLimit-Limit: 10, X-RateLimit-Remaining: 9, and X-RateLimit-Reset showing a Unix timestamp.

Now for the real test. Before you run the next command, predict: what do you think will happen on the 11th request if the limit is 10?

  • Fire 11 rapid requests and see each status code by running:
for i in $(seq 1 11); do echo "Request $i: $(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/api/ping)"; done

The first 10 requests return 200. The 11th returns 429. Your rate limiter is working.

All 11 requests returned 200?

  • Make sure you saved src/index.ts and restarted the server. The old server process does not pick up code changes automatically.
  • Check that your fixedWindowLimiter is passed as the second argument to app.get(), before the route handler.
  • If your Redis container stopped, restart it with docker start redis-rate-limiter and try again.

Help me debug why all 11 requests returned 200.

✔️ Awesome, I've got everything!

Great. Make sure all three files are saved before moving on.

ⓧ I'd like to double check the full code

Here is what each file should look like at this point.

src/limiters/fixedWindow.ts

import Redis from "ioredis";

export interface RateLimitResult {
  allowed: boolean;
  remaining: number;
  resetAt: number;
}

export async function checkFixedWindow(
  redis: Redis,
  key: string,
  windowMs: number,
  max: number
): Promise<RateLimitResult> {
  const windowSec = Math.ceil(windowMs / 1000);
  const windowKey = `rl:fixed:${key}:${Math.floor(Date.now() / windowMs)}`;

  const count = await redis.incr(windowKey);

  if (count === 1) {
    await redis.expire(windowKey, windowSec);
  }

  const allowed = count <= max;
  const remaining = Math.max(0, max - count);
  const resetAt = (Math.floor(Date.now() / windowMs) + 1) * windowMs;

  return { allowed, remaining, resetAt };
}

src/middleware/rateLimit.ts

import { Request, Response, NextFunction } from "express";
import Redis from "ioredis";
import { checkFixedWindow } from "../limiters/fixedWindow";

interface RateLimitOptions {
  redis: Redis;
  windowMs: number;
  max: number;
  algorithm: "fixed" | "sliding";
}

export function createRateLimitMiddleware(options: RateLimitOptions) {
  const { redis, windowMs, max, algorithm } = options;

  return async (req: Request, res: Response, next: NextFunction) => {
    const clientKey = req.ip || "unknown";
    const result = await checkFixedWindow(redis, clientKey, windowMs, max);

    res.set("X-RateLimit-Limit", String(max));
    res.set("X-RateLimit-Remaining", String(result.remaining));
    res.set("X-RateLimit-Reset", String(Math.ceil(result.resetAt / 1000)));

    if (!result.allowed) {
      const retryAfter = Math.max(1, Math.ceil((result.resetAt - Date.now()) / 1000));
      res.set("Retry-After", String(retryAfter));
      res.status(429).json({ error: "Too Many Requests", retryAfter });
      return;
    }

    next();
  };
}

src/index.ts

import express from "express";
import Redis from "ioredis";
import { createRateLimitMiddleware } from "./middleware/rateLimit";

const app = express();
const redis = new Redis({ host: "127.0.0.1", port: 6379 });

redis.on("connect", () => {
  console.log("Connected to Redis");
});

redis.on("error", (err) => {
  console.error("Redis connection error:", err.message);
});

const fixedWindowLimiter = createRateLimitMiddleware({
  redis,
  windowMs: 60000,
  max: 10,
  algorithm: "fixed",
});

app.get("/api/ping", fixedWindowLimiter, (_req, res) => {
  res.json({ message: "pong" });
});

const PORT = 3000;
app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

Your API now has teeth. It counts requests in Redis, serves rate-limit headers on every response, and returns 429 when a client goes over the limit. Next up, you will break this limiter on purpose to discover why fixed windows are not good enough for production.

Break the Fixed Window

Your rate limiter works. Hit the endpoint too many times and you get a 429. But there is a well-known flaw hiding in the fixed-window algorithm that lets a client double the allowed rate with nothing more than good timing.

A client can exhaust its full quota at the tail end of one window, wait for the boundary to roll over, then immediately fire another full quota. The counter resets at the boundary, so the limiter sees each batch as a fresh window. In this step, you will write a test script that exploits this flaw and watch it succeed.

In this step, get ready to:
  • Write a burst test script that times requests around a window boundary.
  • Run the test and observe ~20 requests getting through a limit of 10.
Write the burst test script

The trick to exploiting the fixed-window flaw is timing. Your test needs to fire requests right before a window boundary, then fire again right after. The counter resets at the boundary, so both batches get the full quota.

  • Create a new folder called tests inside your src folder.
  • Create a new file called burstTest.ts inside src/tests/.
  • Add the testBurst function by pasting the following code into burstTest.ts:
async function testBurst(endpoint: string, label: string) {
  const BASE = "http://localhost:3000";
  let successCount = 0;

  // Fire 10 requests to exhaust the current window
  for (let i = 0; i < 10; i++) {
    const res = await fetch(`${BASE}${endpoint}`);
    if (res.status === 200) successCount++;
  }

  // Wait briefly for the window to roll over
  const windowMs = 60000;
  const now = Date.now();
  const nextWindow = (Math.floor(now / windowMs) + 1) * windowMs;
  const waitMs = nextWindow - now + 100;

  console.log(`${label}: waiting ${waitMs}ms for window boundary...`);
  await new Promise((resolve) => setTimeout(resolve, waitMs));

  // Fire 10 more immediately after the boundary
  for (let i = 0; i < 10; i++) {
    const res = await fetch(`${BASE}${endpoint}`);
    if (res.status === 200) successCount++;
  }

  console.log(
    `${label} burst: ${successCount} requests got through (limit was 10)`
  );
  return successCount;
}

What does this code do?

This function exploits the fixed-window boundary in three phases:

  • Phase 1: Fire 10 requests to exhaust the current window's quota. Every request that returns 200 counts as a success.
  • Phase 2: Calculate exactly how many milliseconds remain until the next window boundary, then sleep until just past it. The + 100 adds a small buffer to make sure the boundary has definitely passed.
  • Phase 3: Fire 10 more requests immediately after the boundary. Because the fixed window resets its counter at the boundary, these requests get a fresh quota.
  • Add the main function below the testBurst function to run the test:
async function main() {
  console.log("Starting burst tests...\n");

  const fixedResult = await testBurst("/api/ping", "Fixed window");
  const slidingResult = await testBurst("/api/data", "Sliding window");

  console.log("\n--- Results ---");
  console.log(`Fixed window:   ${fixedResult} requests (expect ~20)`);
  console.log(`Sliding window: ${slidingResult} requests (expect ~10)`);
}

main().catch(console.error);

What does this code do?

  • The main function runs the burst test against two endpoints: /api/ping (fixed window) and /api/data (sliding window).
  • After both tests finish, it prints a summary comparing how many requests got through each limiter. The fixed window should let ~20 through. The sliding window should cap at ~10.
  • Save src/tests/burstTest.ts.

✔️ Awesome, I've got everything!

Great. Make sure you have saved burstTest.ts before moving on.

ⓧ I'd like to double check the full code

Scroll up and confirm you have both chunks in src/tests/burstTest.ts, in this order:

  • The testBurst function (fires 10 requests, waits for the boundary, fires 10 more).
  • The main function that calls testBurst for both /api/ping and /api/data, followed by main().catch(console.error).
Run the burst test and observe the vulnerability

Your limiter is set to 10 requests per 60-second window. The burst test fires 10 requests before the boundary and 10 after. If the limiter is working correctly, only 10 total should get through across that span of time.

Before you run the test, think about what you expect. Should the fixed-window limiter catch this, or will all 20 requests succeed?

  • Make sure the server is still running in your other terminal. If it is not, start it by running:
npx tsx src/index.ts
  • Open a second terminal in your rate-limiter project folder.
  • Run the burst test by running this command:
npx tsx src/tests/burstTest.ts

This test takes about a minute

The script needs to wait for a 60-second window boundary to roll over. You will see a log line like Fixed window: waiting 45000ms for window boundary... while it waits. The actual wait time depends on where you are in the current window. The test then repeats for the sliding window endpoint.

When both tests finish, you will see output like this:

Fixed window burst: 20 requests got through (limit was 10)
Sliding window burst: 10 requests got through (limit was 10)

--- Results ---
Fixed window:   20 requests (expect ~20)
Sliding window: 10 requests (expect ~10)

The fixed-window limiter allowed ~20 requests through, despite a limit of 10. That is double the intended rate, all from one client within a few seconds.

Why does this happen?

The fixed-window counter resets to zero at every boundary. A client that sends 10 requests at the tail end of window A sees them all succeed. When window B starts a moment later, the counter is fresh, so 10 more requests succeed immediately.

From the limiter's perspective, each batch is in a separate window, so everything is fine. From the server's perspective, 20 requests arrived within seconds. The sliding window result (~10) hints at the solution you will build in the next step.

Seeing fewer than 20 for fixed window?

  • If you recently tested /api/ping manually with curl, some of the current window's quota may already be used. Wait 60 seconds for the window to reset, then re-run the test.
  • Make sure your server is running on port 3000. Check the terminal where you started it for the Server running on port 3000 message.

Help me debug my burst test results.

You have just proven that the fixed-window algorithm has a real vulnerability. Any client that knows (or guesses) when your windows reset can double their allowed rate. In the next step, you will fix this with a sliding-window counter powered by an atomic Redis Lua script.

Fix It with a Sliding-Window Lua Script

You just proved that the fixed-window algorithm has a fatal flaw. A client that times requests around the window boundary gets double the allowed rate.

The sliding window algorithm fixes this by blending the previous window's count into the current one, weighted by how far into the current window the request arrives. You'll implement it as a Lua script that Redis executes atomically, so no concurrent request can sneak through between the read and the write.

In this step, get ready to:
  • Write a sliding-window rate limiter powered by an atomic Redis Lua script.
  • Connect it to a new /api/data endpoint.
  • Prove the boundary burst problem disappears.
Create the sliding window Lua script

The heart of the sliding window is a Lua script that runs inside Redis. Because Redis executes Lua scripts as a single atomic operation, no other request can read or write between the count check and the increment.

  • Create a new file called src/limiters/slidingWindow.ts and add the imports and Lua script constant by pasting this code:
import Redis from "ioredis";
import { RateLimitResult } from "./fixedWindow";

// Lua script runs atomically inside Redis
const LUA_SCRIPT = `
local currentKey = KEYS[1]
local previousKey = KEYS[2]
local max = tonumber(ARGV[1])
local windowMs = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local windowSec = math.ceil(windowMs / 1000)

local currentCount = tonumber(redis.call('GET', currentKey) or '0')
local previousCount = tonumber(redis.call('GET', previousKey) or '0')

-- Blend previous window's count with current using elapsed fraction
local elapsedMs = now % windowMs
local weight = 1 - (elapsedMs / windowMs)
local estimatedCount = math.floor(previousCount * weight) + currentCount

if estimatedCount >= max then
  return {0, 0, currentCount, previousCount}
end

redis.call('INCR', currentKey)
redis.call('EXPIRE', currentKey, windowSec * 2)

return {1, max - estimatedCount - 1, currentCount + 1, previousCount}
`;

What does this Lua script do?

  • It reads two Redis STRING keys: the counter for the current window and the counter for the previous window.
  • It calculates weight: the fraction of the previous window that is still "relevant." If you're 30% into the current window, 70% of the previous window's count still applies.
  • estimatedCount blends both windows together. If this estimate is at or above the limit, the request is denied immediately.
  • If the request is allowed, the script atomically increments the current window's counter and sets a TTL. No other request can slip in between the check and the increment.
  • Below the LUA_SCRIPT constant, add the TypeScript function that calls it:
export async function checkSlidingWindow(
  redis: Redis,
  key: string,
  windowMs: number,
  max: number
): Promise<RateLimitResult> {
  const now = Date.now();
  // Build keys for current and previous time windows
  const currentWindow = Math.floor(now / windowMs);
  const currentKey = `rl:sliding:${key}:${currentWindow}`;
  const previousKey = `rl:sliding:${key}:${currentWindow - 1}`;

  // Execute the Lua script atomically in Redis
  const result = (await redis.eval(
    LUA_SCRIPT,
    2,
    currentKey,
    previousKey,
    String(max),
    String(windowMs),
    String(now)
  )) as number[];

  // Unpack the Lua script's return array
  const allowed = result[0] === 1;
  const remaining = Math.max(0, result[1]);
  const resetAt = (currentWindow + 1) * windowMs;

  return { allowed, remaining, resetAt };
}

What does this function do?

  • It constructs two Redis keys: one for the current time window and one for the previous window.
  • redis.eval() sends the Lua script to Redis along with both keys and the arguments (limit, window size, current timestamp). Redis runs the entire script as a single atomic operation.
  • The function unpacks the result array into the same RateLimitResult shape your fixed-window limiter uses, so the middleware works with both interchangeably.
  • Save src/limiters/slidingWindow.ts.

Seeing a red squiggly under the import?

  • Make sure src/limiters/fixedWindow.ts exists and exports RateLimitResult. The sliding window imports that interface from the fixed-window file you created earlier.
  • Check that your file is saved inside the src/limiters/ folder, not in the project root.
  • help me debug an import error in slidingWindow.ts

✔️ Awesome, I've got everything!

Great. Double check you've saved src/limiters/slidingWindow.ts.

ⓧ I'd like to double check the full code

The full file has two sections. Scroll up and confirm each is present, in order:

  • The imports and LUA_SCRIPT constant (starting with import Redis from "ioredis" and ending with the closing backtick and semicolon).
  • The checkSlidingWindow function (starting with export async function checkSlidingWindow and ending with the closing brace).
Prepare the fallback and restart the server

Your rate-limit middleware is designed to fall back to an in-memory counter when Redis goes down. Before restarting the server, you need to create this fallback handler so the middleware's import resolves.

  • Create a new file called src/limiters/memoryFallback.ts and paste the following code:
import { RateLimitResult } from "./fixedWindow";

// In-process counter store with automatic cleanup
const counters = new Map<string, { count: number; resetAt: number }>();

export function checkMemoryFallback(
  key: string,
  windowMs: number,
  max: number
): RateLimitResult {
  const now = Date.now();
  const entry = counters.get(key);

  if (!entry || now >= entry.resetAt) {
    // Create a new counter for this window
    const resetAt = now + windowMs;
    counters.set(key, { count: 1, resetAt });

    setTimeout(() => {
      counters.delete(key);
    }, windowMs);

    return { allowed: true, remaining: max - 1, resetAt };
  }

  // Increment existing counter and check against limit
  entry.count += 1;
  const allowed = entry.count <= max;
  const remaining = Math.max(0, max - entry.count);

  return { allowed, remaining, resetAt: entry.resetAt };
}

What does this code do?

This is a simple in-process Map-based counter. It tracks request counts per client key and automatically cleans up expired entries with setTimeout. You'll explore exactly when and why this fallback kicks in during Step 5.

  • Save src/limiters/memoryFallback.ts.

✔️ Awesome, I've got everything!

Great. Double check you've saved src/limiters/memoryFallback.ts.

ⓧ I'd like to double check the full code

import { RateLimitResult } from "./fixedWindow";

// In-process counter store with automatic cleanup
const counters = new Map<string, { count: number; resetAt: number }>();

export function checkMemoryFallback(
  key: string,
  windowMs: number,
  max: number
): RateLimitResult {
  const now = Date.now();
  const entry = counters.get(key);

  if (!entry || now >= entry.resetAt) {
    // Create a new counter for this window
    const resetAt = now + windowMs;
    counters.set(key, { count: 1, resetAt });

    setTimeout(() => {
      counters.delete(key);
    }, windowMs);

    return { allowed: true, remaining: max - 1, resetAt };
  }

  // Increment existing counter and check against limit
  entry.count += 1;
  const allowed = entry.count <= max;
  const remaining = Math.max(0, max - entry.count);

  return { allowed, remaining, resetAt: entry.resetAt };
}

Your src/index.ts already has a slidingWindowLimiter wired to GET /api/data, and the middleware already knows how to route the "sliding" algorithm to your new checkSlidingWindow function. Now that all the imports resolve, restart the server.

  • Switch back to the terminal running your server and press Ctrl+C to stop it.
  • Restart the server by running:
npx tsx src/index.ts

You should see Connected to Redis and Server running on port 3000 in the terminal.

  • Open a second terminal and test the new sliding-window endpoint by running:
curl -i http://localhost:3000/api/data

You should see a 200 response with X-RateLimit-Limit: 10 and X-RateLimit-Remaining: 9 in the headers, plus a JSON body containing "here is your data".

Server crashing on startup?

  • If you see a "Cannot find module" error, double check that both src/limiters/slidingWindow.ts and src/limiters/memoryFallback.ts exist inside the src/limiters/ folder.
  • If you see a "port already in use" error, your old server is still running. Press Ctrl+C in the original terminal first, then try again.
  • help me debug my server startup error
Run both burst tests and compare

Your src/tests/burstTest.ts already tests both endpoints: /api/ping (fixed window) and /api/data (sliding window). Running it will fire the same boundary burst attack against each algorithm and compare the results.

How long does the test take?

Each burst test waits for a 60-second window boundary to roll over, then fires requests immediately after. The test runs two endpoints back-to-back, so expect a wait of up to two minutes total. Watch the terminal for progress updates.

Before you run this, think about what you expect. The fixed window let ~20 requests through in Step 3. Will the sliding window do the same, or will the blended count cap it closer to 10?

  • In your second terminal, run the burst test by running:
npx tsx src/tests/burstTest.ts

The fixed window result will show ~20 requests got through, just like before. The sliding window result shows only ~10 requests got through, proving the boundary exploit is gone.

Why does the sliding window cap at ~10?

When the window boundary rolls over, the sliding window doesn't reset the count to zero. It blends the previous window's count using the weight formula: previousCount * (1 - elapsedFraction) + currentCount.

Right after the boundary, elapsedFraction is close to zero, so nearly 100% of the previous window's count still applies. The 10 requests you fired at the end of the old window are still "visible" to the algorithm, so the second batch gets blocked immediately.

Seeing unexpected numbers?

  • If both endpoints show the same result, check that src/index.ts uses algorithm: "fixed" for /api/ping and algorithm: "sliding" for /api/data.
  • If counts are lower than expected, earlier curl requests may have consumed part of the window. Wait 60 seconds for the window to reset and run the test again.
  • help me debug my burst test results

Your sliding-window Lua script just eliminated the boundary burst vulnerability. The fixed window still lets ~20 requests through, but the sliding window holds firm at ~10. Next up, you'll make this system resilient to Redis outages with an in-memory fallback.

Add an In-Memory Fallback

In the previous step, you proved the sliding window eliminates boundary bursts. Your rate limiter is accurate now.

But what happens when Redis itself goes down? Without a fallback, your API either lets all traffic through unprotected or crashes entirely. In this step, you'll build an in-memory counter that steps in automatically when Redis is unavailable.

In this step, get ready to:
  • Create an in-memory rate limiter that shares the same interface as the Redis limiters.
  • Test the fallback by stopping the Redis container and confirming rate limiting still works.
Create the in-memory fallback limiter

Your Redis-backed limiters need a backup plan. The fallback uses a plain JavaScript Map to count requests in-process, with the same RateLimitResult interface so the middleware can swap between them seamlessly.

  • Create a new file src/limiters/memoryFallback.ts and paste in the following code:
import { RateLimitResult } from "./fixedWindow";

// In-memory request counter, keyed by client IP
const counters = new Map<string, { count: number; resetAt: number }>();

export function checkMemoryFallback(
  key: string,
  windowMs: number,
  max: number
): RateLimitResult {
  const now = Date.now();
  const entry = counters.get(key);

  // Start a fresh window if none exists or window expired
  if (!entry || now >= entry.resetAt) {
    const resetAt = now + windowMs;
    counters.set(key, { count: 1, resetAt });
    // Auto-clean entry after the window expires
    setTimeout(() => {
      counters.delete(key);
    }, windowMs);
    return { allowed: true, remaining: max - 1, resetAt };
  }

  // Increment counter for existing window
  entry.count += 1;
  const allowed = entry.count <= max;
  const remaining = Math.max(0, max - entry.count);

  return { allowed, remaining, resetAt: entry.resetAt };
}

What does this code do?

This limiter mirrors the Redis-based ones but stores everything in the server's own memory. The counters Map holds a count and reset timestamp for each client IP.

  • When a new client arrives (or the window has expired), the function starts a fresh count at 1 and schedules a setTimeout to clean up the entry after the window passes.
  • For repeat requests within the same window, it increments the count and checks against the max.
  • The return type is the same RateLimitResult interface used by checkFixedWindow and checkSlidingWindow, so the middleware can swap in this fallback without any changes to header-setting or 429 logic.
  • Save src/limiters/memoryFallback.ts.

Your middleware in src/middleware/rateLimit.ts already imports and uses this fallback. Open that file and look for the try/catch block inside the returned middleware function:

    try {
      if (algorithm === "sliding") {
        result = await checkSlidingWindow(redis, clientKey, windowMs, max);
      } else {
        result = await checkFixedWindow(redis, clientKey, windowMs, max);
      }
    } catch (err) {
      console.warn("Redis unavailable, using in-memory fallback");
      result = checkMemoryFallback(clientKey, windowMs, max);
    }

How does the middleware use the fallback?

The entire Redis call is wrapped in a try/catch. If Redis throws a connection error, the catch block logs a warning and calls checkMemoryFallback instead.

Because the fallback returns the same RateLimitResult shape, the code that sets X-RateLimit-Limit, X-RateLimit-Remaining, and X-RateLimit-Reset headers runs the same way regardless of which limiter handled the request.

  • In your terminal, stop the running server with Ctrl+C.
  • Restart the server by running:
npx tsx src/index.ts
  • You should see "Connected to Redis" and "Server running on port 3000" in the terminal. The server now resolves the memoryFallback import successfully.

Server not starting?

  • Check that memoryFallback.ts is saved inside the src/limiters/ directory, not in src/ directly. The middleware import path expects ../limiters/memoryFallback.
  • Make sure the function is named checkMemoryFallback and is exported. The middleware imports it by that exact name.
  • If the Redis container stopped while you were editing, restart it with docker start redis-rate-limiter before retrying.

Why isn't my server starting after I added the memory fallback file?

✔️ Awesome, I've got everything!

Great. Double check you've saved src/limiters/memoryFallback.ts and that your server is running.

ⓧ I'd like to double check the full code

Here is the complete src/limiters/memoryFallback.ts file. Compare it with yours line by line.

import { RateLimitResult } from "./fixedWindow";

const counters = new Map<string, { count: number; resetAt: number }>();

export function checkMemoryFallback(
  key: string,
  windowMs: number,
  max: number
): RateLimitResult {
  const now = Date.now();
  const entry = counters.get(key);

  if (!entry || now >= entry.resetAt) {
    const resetAt = now + windowMs;
    counters.set(key, { count: 1, resetAt });

    setTimeout(() => {
      counters.delete(key);
    }, windowMs);

    return { allowed: true, remaining: max - 1, resetAt };
  }

  entry.count += 1;
  const allowed = entry.count <= max;
  const remaining = Math.max(0, max - entry.count);

  return { allowed, remaining, resetAt: entry.resetAt };
}
Test the fallback by stopping Redis

The real test of a fallback is pulling the rug out from under it. You'll stop the Redis container and confirm the API keeps rejecting excessive traffic.

  • Open a second terminal window. Keep the first terminal running the server.
  • Stop the Redis container by running:
docker stop redis-rate-limiter
  • Check the terminal running your server. You should see a "Redis connection error" message appear.

Before you send any requests, predict what will happen. Do you think the API will let all traffic through, reject everything, or still enforce the rate limit?

  • Hit the /api/data endpoint 11 times rapidly by running:
for i in $(seq 1 11); do curl -s -o /dev/null -w "Request $i: %{http_code}\n" http://localhost:3000/api/data; done

What does this command do?

This fires 11 curl requests in a loop. The -s flag silences progress output, -o /dev/null discards the response body, and -w "%{http_code}" prints only the HTTP status code for each request.

The first 10 requests return 200. The 11th returns 429. Rate limiting is still alive, even without Redis.

  • Switch to the terminal running your server. You should see multiple lines of "Redis unavailable, using in-memory fallback" logged by the middleware's catch block.

Why not just fail open?

"Fail open" means letting all traffic through when the limiter breaks. That defeats the purpose of rate limiting and can lead to a stampede of requests overwhelming your backend.

The in-memory fallback is a "graceful degradation" strategy. It keeps protection alive at the cost of per-server accuracy. In a multi-server deployment, each server tracks its own counts independently, so the effective limit is multiplied. That's still far better than no limit at all.

  • Restart the Redis container by running:
docker start redis-rate-limiter
  • Check the server terminal. You should see "Connected to Redis" appear again, confirming the connection is restored.

All 11 requests returned 200?

  • Make sure you stopped Redis before running the curl loop. Run docker ps to confirm the redis-rate-limiter container is not listed.
  • If you waited too long between tests, the in-memory window may have expired. Run the curl loop again immediately after stopping Redis to see the 429.
  • Verify that checkMemoryFallback is correctly imported and called inside the catch block in src/middleware/rateLimit.ts.

Why is my in-memory fallback not kicking in when Redis is stopped?

Your rate limiter now handles the worst case gracefully. Redis goes down, the in-memory counter takes over, and the API stays protected. You've built a production-quality middleware with two algorithms, proper HTTP headers, and a resilience layer. Next up, there's an optional Secret Mission to push your skills further.

Secret mission

Implement a Token Bucket Algorithm

You've built two rate-limiting algorithms, but there's a third one that powers APIs like GitHub and Stripe. The token bucket lets clients burst when they have saved-up capacity, then throttles them back to a steady average rate. Can you implement it with a Redis Lua script?

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 with a Docker container, so there are no ongoing costs.

Resources you used:

  • Docker container redis-rate-limiter running Redis 7 on port 6379.
  • Project directory rate-limiter on your Desktop containing all TypeScript source files and node_modules.

Keep everything running

No action needed. Choose this if you're still actively building or experimenting with different rate-limiting algorithms.

  • Your redis-rate-limiter Docker container will continue running locally at no cost.
  • Your project files in the rate-limiter directory remain on your Desktop.

Pause - I'll come back to this later

Shut down running processes to free up memory while keeping your files and container intact for later.

  • If your Express server is still running in the terminal, press Ctrl+C to stop it.
  • Stop the Redis container by running:
docker stop redis-rate-limiter
  • When you're ready to come back, restart the container by running:
docker start redis-rate-limiter
  • Then start your server again with npx tsx src/index.ts from inside your rate-limiter directory.

Delete - I don't want to use this again

Remove all project resources and start fresh.

  • If your Express server is still running in the terminal, press Ctrl+C to stop it.
  • Remove the Redis Docker container by running:
docker rm -f redis-rate-limiter
  • Delete the project directory by running:

macOS

cd ~/Desktop
rm -rf rate-limiter

Windows

cd ~\Desktop
rmdir /s /q rate-limiter
  • Confirm the container is gone by running:
docker ps -a --filter name=redis-rate-limiter
  • You should see an empty list with no containers.

Nice Work!

Nice Work!

You just built a production-grade distributed rate limiter in TypeScript and Redis. You can now explain fixed-window flaws, sliding-window math, and graceful degradation in a system design interview.

You've learned how to:

  • Built two rate-limiting algorithms (fixed window and sliding window) and discovered firsthand how the fixed-window boundary burst lets clients double their allowed rate.
  • Written atomic Redis Lua scripts that guarantee no concurrent request slips through between a read and a write, and wired them into reusable Express middleware with standard HTTP rate-limit headers (X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After).
  • Added an in-memory fallback that keeps the API protected when Redis goes down, proving your system degrades gracefully instead of failing completely.
  • Secret Mission: completed an optional challenge to push your rate-limiting skills even further.

Ready to quiz yourself?