DEV Community

Cover image for Building an API Rate Limiter from Scratch with Node.js and Redis
Sruthi sri G
Sruthi sri G

Posted on

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 (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)]
Enter fullscreen mode Exit fullscreen mode

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).

  1. Every incoming request consumes 1 token.
  2. If tokens are available, the request is allowed.
  3. If the bucket is empty (0 tokens), the request is rejected with HTTP 429 Too Many Requests.
  4. 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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Ensure your tsconfig.json contains:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "outDir": "./dist"
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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:

  1. Request A reads: 1 token remaining.
  2. Request B reads: 1 token remaining.
  3. 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 };
}
Enter fullscreen mode Exit fullscreen mode

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();
    }
  };
}
Enter fullscreen mode Exit fullscreen mode

Design Pattern Note: Fail-Open vs Fail-Closed

Notice the catch block calls next(). 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}`);
});
Enter fullscreen mode Exit fullscreen mode

Step 5: Testing with cURL

Run your server:

npx ts-node src/index.ts
Enter fullscreen mode Exit fullscreen mode

In another terminal, send 6 rapid requests:

for i in {1..6}; do curl -i http://localhost:3000/api/sensitive-action; done
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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..."}
Enter fullscreen mode Exit fullscreen mode

Wait 4 seconds, send the request again, and tokens will have refilled!


Key Takeaways

  1. Token Bucket gives you the best of both worlds: it handles sudden legitimate traffic bursts while maintaining a strict long-term average rate.
  2. Atomic Lua Scripts are essential in distributed environments to prevent double-spending and race conditions.
  3. 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)