Rate limiting is one of those backend concerns that gets bolted on as an afterthought — usually after an API gets hammered, a bill spikes, or a client's app goes down under unexpected load. The typical fix is to install express-rate-limit, wire it up in five minutes, and move on. That works, until it doesn't. Understanding what's happening under the hood lets you make better architecture decisions and debug subtle issues when traffic patterns get weird.

This guide skips the shortcut and builds a rate limiter from first principles in Node.js — two algorithms, real middleware code, and a clear decision framework for when to roll your own versus reach for a library.


Why Rate Limiting Belongs in Your Core Architecture

A rate limiter does three things for your API:

  • Protects infrastructure from accidental or malicious traffic spikes
  • Enforces fair usage across tenants in a multi-tenant SaaS product
  • Provides a security layer against brute-force attacks on auth endpoints

Without it, a single misconfigured client polling your API every 100ms can saturate your database connection pool and degrade service for every other user. That is not a hypothetical — it is a common production incident.


Understanding the Two Core Algorithms

Token Bucket

Imagine a bucket that holds a fixed number of tokens. Every incoming request consumes one token. Tokens refill at a steady rate over time. If the bucket is empty, the request is rejected.

This algorithm naturally allows short bursts of traffic while enforcing an average rate. It is well-suited for user-facing APIs where a human might legitimately fire a few rapid requests, then go idle.

Sliding Window

A sliding window counter tracks how many requests arrived in the last N seconds from a given key (usually an IP or user ID). Unlike a fixed window (which resets on the clock minute and can be gamed at the boundary), a sliding window evaluates a rolling time range relative to now.

It is stricter about burst behaviour and easier to reason about for compliance use cases (e.g., "no more than 100 requests per 60 seconds, evaluated continuously").


Building the Token Bucket Middleware

Here is a clean, in-memory token bucket implementation for Express. This is suitable for single-process Node.js services or prototyping.

// rateLimiter.js — Token Bucket (in-memory)
const buckets = new Map();

function tokenBucketLimiter({ capacity = 10, refillRate = 1 } = {}) {
  // refillRate: tokens added per second

  return function (req, res, next) {
    const key = req.ip;
    const now = Date.now();

    if (!buckets.has(key)) {
      buckets.set(key, { tokens: capacity, lastRefill: now });
    }

    const bucket = buckets.get(key);
    const elapsed = (now - bucket.lastRefill) / 1000; // seconds
    const refilled = Math.min(capacity, bucket.tokens + elapsed * refillRate);

    bucket.tokens = refilled;
    bucket.lastRefill = now;

    if (bucket.tokens >= 1) {
      bucket.tokens -= 1;
      return next();
    }

    res.setHeader("Retry-After", Math.ceil((1 - bucket.tokens) / refillRate));
    return res.status(429).json({ error: "Too Many Requests" });
  };
}

module.exports = tokenBucketLimiter;

Wire it into your Express app as middleware on any route or router:

const tokenBucketLimiter = require("./rateLimiter");
app.use("/api/", tokenBucketLimiter({ capacity: 20, refillRate: 2 }));

A capacity of 20 with a refill rate of 2 tokens per second means a client can burst up to 20 requests instantly, then sustain 2 requests per second indefinitely — a reasonable profile for a search or autocomplete endpoint.


Building the Sliding Window Middleware

// slidingWindow.js — Sliding Window (in-memory)
const windows = new Map();

function slidingWindowLimiter({ limit = 100, windowMs = 60_000 } = {}) {
  return function (req, res, next) {
    const key = req.ip;
    const now = Date.now();
    const windowStart = now - windowMs;

    if (!windows.has(key)) {
      windows.set(key, []);
    }

    // Keep only timestamps within the current window
    const timestamps = windows.get(key).filter((t) => t > windowStart);
    timestamps.push(now);
    windows.set(key, timestamps);

    const remaining = limit - timestamps.length;
    res.setHeader("X-RateLimit-Limit", limit);
    res.setHeader("X-RateLimit-Remaining", Math.max(0, remaining));

    if (timestamps.length > limit) {
      return res.status(429).json({ error: "Rate limit exceeded. Try again later." });
    }

    next();
  };
}

module.exports = slidingWindowLimiter;

The X-RateLimit-* headers are important — they let well-behaved clients back off gracefully instead of hammering the API and receiving a wall of 429s.


Critical Limitation: In-Memory State Does Not Scale

Both implementations above store state in a Map on the Node.js heap. This creates two production problems:

  1. State is lost on restart. Every deploy resets all rate limit counters.
  2. State is not shared across instances. If you run three Node.js processes behind a load balancer, each process tracks its own counters — a client effectively gets a 3× multiplied limit.

The standard solution is to move state into Redis, using atomic operations (INCR, EXPIRE, sorted sets for sliding windows) to ensure consistency across instances. This is exactly what libraries like rate-limiter-flexible or express-rate-limit with a Redis store handle for you.


When to Roll Your Own vs. Use a Library

ScenarioRecommendation
Single-process service, low trafficIn-memory implementation is fine
Multiple instances / horizontal scalingUse Redis-backed library
Custom rate limit logic (per-plan, per-endpoint)Roll your own on top of Redis primitives
Fast prototyping or internal toolingexpress-rate-limit with default store
Compliance or billing-critical enforcementRedis + persistent logging, custom implementation

The rule of thumb: use a battle-tested library when the algorithm fits your needs out of the box. Build your own when your business logic — tiered SaaS plans, per-endpoint budgets, dynamic limits from a database — cannot be cleanly expressed through a library's configuration API.


Hardening for Production

A few things the toy implementations above omit:

  • Key diversity: Rate limit by user ID (from JWT), not just IP. IPs can be shared (NAT, corporate proxies) or spoofed.
  • Distributed tracing: Log every 429 with the key, endpoint, and timestamp. You will need this data when a legitimate client files a support ticket.
  • Graceful degradation: If your Redis instance is unavailable, decide upfront whether to fail open (allow all traffic) or fail closed (block all traffic). Neither is universally correct — it depends on your threat model.
  • Endpoint-specific limits: Auth endpoints (/login, /forgot-password) should have tighter limits than read endpoints.

Why This Matters for Your Project

Whether you are building a public API, a multi-tenant SaaS platform, or a mobile backend, rate limiting is load management, security policy, and business logic rolled into one layer of middleware. Getting the algorithm right from the start — and understanding when in-memory state becomes a liability — saves you from painful retrofits under production pressure. If your application is scaling horizontally or you are onboarding paying customers with differentiated usage tiers, this is the right time to move beyond defaults and build a rate limiting strategy that actually reflects your architecture.