Building an API Rate Limiter from Scratch with Node.js and Redis
When building public APIs, unthrottled endpoints are an invitation for trouble. A rogue client script, a scraping bot, or a distributed denial-of-service (DDoS) spike can exhaust your database connections and crash your services.
While off-the-shelf NPM packages exist, relying on black-box middleware without understanding how it manages race conditions in distributed environments is dangerous.
In this tutorial, we will build a production-ready rate limiter from scratch in TypeScript, Node.js, and Redis using the Token Bucket Algorithm.
Why Redis?
In a multi-instance deployment (e.g., behind a load balancer on AWS or Kubernetes), in-memory counters inside a single Node.js process do not work:
[Client Request] ──► [Load Balancer]
├──────► [Node Instance A (Counter: 1)]
└──────► [Node Instance B (Counter: 1)]
If a client sends 10 requests, they get split across instances, allowing the client to bypass the limit. Redis provides a centralized, blazing-fast in-memory store that all app servers query.
How the Token Bucket Algorithm Works
Imagine a bucket that holds a maximum capacity of tokens (e.g., 10 tokens).
- Every incoming request consumes 1 token.
- If tokens are available, the request is allowed.
- If the bucket is empty (
0 tokens), the request is rejected withHTTP 429 Too Many Requests. - Over time, tokens refill at a steady rate (e.g.,
1 token every second) up to the bucket capacity.
Tokens refill at a fixed rate (e.g. +2/sec)
│
▼
┌─────────────────┐
│ Token Bucket │
│ [●] [●] [●] │ Capacity = 5
└────────┬────────┘
│
Request Arrives? │ Consumes 1 Token
▼
Tokens > 0? ──► YES ──► Allow (200 OK)
└──► NO ──► Block (429 Too Many Requests)
Project Setup
Initialize a new TypeScript Node.js project:
mkdir rate-limiter-redis && cd rate-limiter-redis
npm init -y
npm install express ioredis dotenv
npm install -D typescript @types/node @types/express ts-node
npx tsc --init
Ensure your tsconfig.json contains:
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "./dist"
}
}
Step 1: Initialize the Redis Client
Create src/redis.ts:
// src/redis.ts
import Redis from "ioredis";
const REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379";
export const redis = new Redis(REDIS_URL, {
maxRetriesPerRequest: 3,
enableReadyCheck: true,
lazyConnect: false,
});
redis.on("connect", () => {
console.log("Connected to Redis successfully.");
});
redis.on("error", (err) => {
console.error("Redis connection error:", err);
});
Step 2: The Concurrency Problem & Lua Scripting
If we read the current token count and update it with two separate Redis commands (GET followed by SET), concurrent requests from the same user create a race condition:
- Request A reads: 1 token remaining.
- Request B reads: 1 token remaining.
- Both deduct 1 token, allowing 2 requests when only 1 should have passed.
To make the check-and-decrement step completely atomic, we execute the logic inside a Redis Lua script. Redis executes Lua scripts sequentially on a single thread, guaranteeing zero race conditions.
Create src/rateLimiter.ts:
// src/rateLimiter.ts
import { redis } from "./redis";
// Lua script for atomic Token Bucket execution
const TOKEN_BUCKET_LUA = `
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2]) -- tokens per millisecond
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
-- Retrieve current bucket state
local data = redis.call("HMGET", key, "tokens", "last_updated")
local tokens = tonumber(data[1])
local last_updated = tonumber(data[2])
if not tokens then
-- First request: initialize bucket
tokens = capacity
last_updated = now
else
-- Calculate refilled tokens based on elapsed time
local elapsed = math.max(0, now - last_updated)
local refilled = elapsed * refill_rate
tokens = math.min(capacity, tokens + refilled)
last_updated = now
end
-- Check if enough tokens exist
if tokens >= requested then
tokens = tokens - requested
redis.call("HMSET", key, "tokens", tokens, "last_updated", last_updated)
-- Expire bucket after 1 hour of inactivity to conserve memory
redis.call("EXPIRE", key, 3600)
return {1, math.floor(tokens)} -- Allowed: 1, remaining tokens
else
redis.call("HMSET", key, "tokens", tokens, "last_updated", last_updated)
redis.call("EXPIRE", key, 3600)
return {0, math.floor(tokens)} -- Blocked: 0, remaining tokens
end
`;
export interface RateLimitConfig {
capacity: number; // Max tokens burst
refillPerSecond: number; // Replenish rate
}
export async function checkRateLimit(
identifier: string,
config: RateLimitConfig
): Promise<{ allowed: boolean; remaining: number }> {
const key = `ratelimit:${identifier}`;
const now = Date.now();
const refillRatePerMs = config.refillPerSecond / 1000;
const result = (await redis.eval(
TOKEN_BUCKET_LUA,
1,
key,
config.capacity,
refillRatePerMs,
now,
1
)) as [number, number];
const allowed = result[0] === 1;
const remaining = result[1];
return { allowed, remaining };
}
Step 3: Create Express Middleware with Standard Headers
RFC specifications recommend including rate limit headers (X-RateLimit-Limit, X-RateLimit-Remaining, Retry-After) so frontend clients can throttle their own requests proactively.
Create src/middleware.ts:
// src/middleware.ts
import { Request, Response, NextFunction } from "express";
import { checkRateLimit, RateLimitConfig } from "./rateLimiter";
export function createRateLimiter(config: RateLimitConfig) {
return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
// Identify user by API key, User ID, or fallback to client IP
const clientIdentifier =
(req.headers["x-api-key"] as string) ||
req.ip ||
req.socket.remoteAddress ||
"unknown-client";
try {
const { allowed, remaining } = await checkRateLimit(clientIdentifier, config);
// Set standard headers
res.setHeader("X-RateLimit-Limit", config.capacity);
res.setHeader("X-RateLimit-Remaining", Math.max(0, remaining));
if (!allowed) {
res.setHeader("Retry-After", Math.ceil(1 / config.refillPerSecond));
res.status(429).json({
error: "Too Many Requests",
message: "You have exceeded your request quota. Please wait before retrying.",
});
return;
}
next();
} catch (error) {
console.error("Rate limiter failure:", error);
// Fail-open: If Redis is down, allow traffic rather than breaking the application
next();
}
};
}
Design Pattern Note: Fail-Open vs Fail-Closed
Notice thecatchblock callsnext(). In high-availability architectures, you often want to fail-open so an infrastructure outage on Redis does not take down your entire public API.
Step 4: Putting It All Together in an Express App
Create src/index.ts:
// src/index.ts
import express from "express";
import { createRateLimiter } from "./middleware";
const app = express();
app.use(express.json());
// Apply limit: 5 requests max burst, refilling 1 token every 2 seconds (0.5 tokens/sec)
const apiLimiter = createRateLimiter({
capacity: 5,
refillPerSecond: 0.5,
});
app.get("/api/public-data", (req, res) => {
res.json({ message: "This is an unthrottled public endpoint." });
});
app.get("/api/sensitive-action", apiLimiter, (req, res) => {
res.json({
success: true,
data: "Access granted to resource.",
timestamp: new Date().toISOString(),
});
});
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Server listening on port ${PORT}`);
});
Step 5: Testing with cURL
Run your server:
npx ts-node src/index.ts
In another terminal, send 6 rapid requests:
for i in {1..6}; do curl -i http://localhost:3000/api/sensitive-action; done
On requests 1 through 5, you will receive HTTP 200 OK with decreasing X-RateLimit-Remaining headers:
HTTP/1.1 200 OK
X-RateLimit-Limit: 5
X-RateLimit-Remaining: 4
On the 6th request, you immediately receive:
HTTP/1.1 429 Too Many Requests
Retry-After: 2
X-RateLimit-Limit: 5
X-RateLimit-Remaining: 0
{"error":"Too Many Requests","message":"You have exceeded your request quota..."}
Wait 4 seconds, send the request again, and tokens will have refilled!
Key Takeaways
- Token Bucket gives you the best of both worlds: it handles sudden legitimate traffic bursts while maintaining a strict long-term average rate.
- Atomic Lua Scripts are essential in distributed environments to prevent double-spending and race conditions.
- Fail-Open Strategy prevents your rate-limiting caching layer from becoming a single point of failure for your entire service.
Next Steps
Want to push this further? Try adding tier-based limits (e.g., 10 req/min for free users, 100 req/min for paid accounts) by looking up user subscription status before invoking the Lua script.
Top comments (0)