Building an API Rate Limiter from Scratch with Node.js and Redis
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
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.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.