Like Blockend? Give it a ⭐ on GitHub.

Star
blockend

Blockend

02 blocks

Rate Limiter

Framework-agnostic rate limiting with pluggable storage backends and RFC-compliant headers.

The Rate Limiter block protects your API from abuse by capping how many requests a client can make within a configurable time window.

Instead of coupling rate limiting to a single framework or opaque npm dependency, it gives you a shared core engine with lightweight adapters for Express, Fastify, and Hono — and pluggable storage via Memory or Redis.


Features

  • Framework-agnostic core engine shared across Express, Fastify, and Hono
  • Pluggable storage: in-memory Map with chronological purge or Redis via atomic Lua script
  • Configurable time window, request limit, and HTTP status code
  • Custom key generation — default extracts client IP, or use any identifier
  • RFC-compliant RateLimit-* standard headers
  • Legacy X-RateLimit-* headers for backward compatibility
  • Retry-After header on blocked requests
  • Fail-open on store errors — a storage outage never takes down your API

File Structure

rate-limiter
├── adapters
│   ├── express.ts
│   ├── fastify.ts
│   └── hono.ts
├── core
│   └── core.ts
├── utils
│   └── ip.ts
└── variants
    ├── memory-store.ts
    └── redis-store.ts
  • adapters/ — Framework-specific middleware that extracts the client key, invokes the core engine, and applies response headers.
  • core/ — The evaluateRateLimit engine and shared TypeScript interfaces (RateLimitConfig, RateLimitStore, RateLimitRecord, RateLimitResult).
  • utils/getClientIp utility for extracting the client IP from proxy headers or socket address.
  • variants/ — Storage implementations: MemoryStore (in-memory map with periodic incremental purge) and RedisStore (atomic Lua script via ioredis).

Installation

pnpm dlx blockend-cli add rate-limiter

Detect Project

Blockend detects your project configuration and determines the correct output location.

Select Adapter

? Which adapter would you like?
  ❯ Express
    Fastify
    Hono

Select Store

? Which store would you like?
  ❯ Memory
    Redis

Install Dependencies

Required packages (e.g. ioredis for Redis) are installed automatically.

Generate Files

The selected adapter, store, and base files are generated into your project.

Copy the files below into your project's blocks directory.

Peer Dependencies

PackageRequired for
expressExpress adapter
fastifyFastify adapter
honoHono adapter
ioredisRedis store

blocks/rate-limiter/core/core.ts

Core rate limiting engine and shared type definitions: evaluateRateLimit, RateLimitConfig, RateLimitStore, RateLimitRecord, and RateLimitResult.

// --- Core Interfaces ---
export interface RateLimitRecord {
  hits: number;
  resetTime: number; // Unix timestamp in ms
}

export interface RateLimitStore {
  increment(key: string, windowMs: number): Promise<RateLimitRecord> | RateLimitRecord;
}

export interface RateLimitConfig {
  windowMs: number;
  max: number;
  statusCode: number;
  message: string | Record<string, unknown>;
  standardHeaders: boolean;
  legacyHeaders: boolean; // <-- Added flag for backwards compatibility
}
export interface RateLimitResult {
  isBlocked: boolean;
  statusCode: number;
  message: string | Record<string, unknown>;
  headers: Record<string, string>;
}

// --- The Core Engine ---
export async function evaluateRateLimit(
  key: string,
  store: RateLimitStore,
  config: RateLimitConfig
): Promise<RateLimitResult> {
  const { hits, resetTime } = await store.increment(key, config.windowMs);
  const remaining = Math.max(0, config.max - hits);
  const now = Date.now();

  const retryAfterSeconds = Math.ceil(Math.max(0, resetTime - now) / 1000);
  const headers: Record<string, string> = {};
  const isBlocked = hits > config.max;

  // 1. Modern Standard Headers (RFC 9421 Draft Compliant)
  if (config.standardHeaders) {
    headers["RateLimit-Limit"] = `${config.max}, window=${Math.ceil(config.windowMs / 1000)}`;
    headers["RateLimit-Remaining"] = String(remaining);
    headers["RateLimit-Reset"] = String(retryAfterSeconds); // Remaining seconds
  }

  // 2. Legacy Headers (De facto Industry Standard - GitHub, Twitter, etc.)
  if (config.legacyHeaders) {
    headers["X-RateLimit-Limit"] = String(config.max);
    headers["X-RateLimit-Remaining"] = String(remaining);
    headers["X-RateLimit-Reset"] = String(Math.ceil(resetTime / 1000)); // Absolute Unix Epoch timestamp in seconds
  }

  if (isBlocked) {
    headers["Retry-After"] = String(retryAfterSeconds);
    return {
      isBlocked: true,
      statusCode: config.statusCode,
      message: config.message,
      headers
    };
  }

  return {
    isBlocked: false,
    statusCode: 200,
    message: "",
    headers
  };
}

blocks/rate-limiter/utils/ip.ts

IP extraction utility that checks x-forwarded-for, x-real-ip, then falls back to the socket remote address.

export function getClientIp(
  headers: Record<string, string | string[] | undefined>,
  remoteAddress?: string
): string {
  // 1. Check standard proxy headers (if you trust your upstream proxy environment)
  const xForwardedFor = headers["x-forwarded-for"];
  if (xForwardedFor) {
    const ips =
      typeof xForwardedFor === "string" ? xForwardedFor.split(",") : xForwardedFor[0]!.split(",");

    return ips[0]!.trim(); // The first IP is the actual client
  }
  const xRealIp = headers["x-real-ip"];
  if (typeof xRealIp === "string") return xRealIp;
  // 2. Fall back to raw socket address
  return remoteAddress || "unknown-ip";
}

blocks/rate-limiter/adapters/express.ts

Express middleware factory that wraps the core engine.

import type { Request, Response, NextFunction } from "express";
import { evaluateRateLimit } from "../core/core";
import type { RateLimitStore, RateLimitConfig } from "../core/core";
import { getClientIp } from "../utils/ip";

export interface ExpressOptions extends Partial<RateLimitConfig> {
  store: RateLimitStore;
  keyGenerator?: (req: Request) => string;
}

export const expressRateLimit = (options: ExpressOptions) => {
  const config: RateLimitConfig = {
    windowMs: 60 * 1000,
    max: 100,
    statusCode: 429,
    message: { error: "Too many requests, please try again later." },
    standardHeaders: true,
    legacyHeaders: true,
    ...options
  };

  const keyGen =
    options.keyGenerator || ((req: Request) => getClientIp(req.headers, req.socket.remoteAddress));

  return async (req: Request, res: Response, next: NextFunction): Promise<void> => {
    try {
      const key = keyGen(req);
      const result = await evaluateRateLimit(key, options.store, config);

      // Set computed rate limit headers dynamically
      for (const [name, value] of Object.entries(result.headers)) {
        res.setHeader(name, value);
      }

      if (result.isBlocked) {
        res.status(result.statusCode).send(result.message);
        return;
      }

      next();
    } catch (error) {
      // oxlint-disable-next-line no-console
      console.error("Rate limiter failure (Fail-Open):", error);
      next(); // Fail-open pattern intact
    }
  };
};

blocks/rate-limiter/adapters/fastify.ts

Fastify onRequest hook factory that wraps the core engine.

import type { FastifyRequest, FastifyReply } from "fastify";
import { evaluateRateLimit } from "../core/core";
import type { RateLimitStore, RateLimitConfig } from "../core/core";
import { getClientIp } from "../utils/ip";

export interface FastifyOptions extends Partial<RateLimitConfig> {
  store: RateLimitStore;
  keyGenerator?: (req: FastifyRequest) => string;
}

export const fastifyRateLimit = (options: FastifyOptions) => {
  const config: RateLimitConfig = {
    windowMs: 60 * 1000,
    max: 100,
    statusCode: 429,
    message: { error: "Too many requests, please try again later." },
    standardHeaders: true,
    legacyHeaders: true,
    ...options
  };

  const keyGen =
    options.keyGenerator ||
    ((req: FastifyRequest) => getClientIp(req.headers, req.socket.remoteAddress));

  return async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
    try {
      const key = keyGen(request);
      const result = await evaluateRateLimit(key, options.store, config);

      reply.headers(result.headers);

      if (result.isBlocked) {
        reply.code(result.statusCode).send(result.message);
        return; // Halts request lifecycle execution in Fastify hooks
      }
    } catch (error) {
      request.log.error(error, "Rate limiter failure (Fail-Open)");
      // Async hook implicit return continues execution automatically (Fail-Open)
    }
  };
};

blocks/rate-limiter/adapters/hono.ts

Hono middleware factory that wraps the core engine.

import { evaluateRateLimit } from "../core/core";
import type { RateLimitStore, RateLimitConfig } from "../core/core";
import { getClientIp } from "../utils/ip";
import type { ContentfulStatusCode } from "hono/utils/http-status";
import type { Context, MiddlewareHandler } from "hono";

export interface HonoOptions extends Partial<RateLimitConfig> {
  store: RateLimitStore;
  keyGenerator?: (c: Context) => string;
}

export const honoRateLimit = (options: HonoOptions): MiddlewareHandler => {
  const config: RateLimitConfig = {
    windowMs: 60 * 1000,
    max: 100,
    statusCode: 429,
    message: { error: "Too many requests, please try again later." },
    standardHeaders: true,
    legacyHeaders: true,
    ...options
  };

  const keyGen =
    options.keyGenerator ||
    ((c: Context) => getClientIp(Object.fromEntries(c.req.raw.headers.entries()), undefined));

  return async (c, next) => {
    try {
      const key = keyGen(c);
      const result = await evaluateRateLimit(key, options.store, config);

      // Set computed rate limit headers dynamically
      for (const [name, value] of Object.entries(result.headers)) {
        c.header(name, value);
      }

      if (result.isBlocked) {
        return c.json(result.message, result.statusCode as ContentfulStatusCode);
      }

      await next();
    } catch (error) {
      //oxlint-disable-next-line no-console
      console.error("Rate limiter failure (Fail-Open):", error);
      await next(); // Fail-open pattern intact
    }
  };
};

blocks/rate-limiter/variants/memory-store.ts

In-memory store using a Map with a chronological expiration queue and bounded incremental purge.

import type { RateLimitRecord, RateLimitStore } from "../core/core";

interface QueueEntry {
  key: string;
  resetTime: number;
}

export class MemoryStore implements RateLimitStore {
  private readonly store = new Map<string, RateLimitRecord>();
  // High-Performance Addition: A chronological queue tracking when keys expire
  private readonly expirationQueue: QueueEntry[] = [];
  private queueIndex = 0;

  public constructor() {
    // Periodically run a bounded incremental purge instead of a full loop
    setInterval(() => this.purge(), 60 * 1000).unref();
  }

  public increment(key: string, windowMs: number): RateLimitRecord {
    const now = Date.now();
    const existing = this.store.get(key);

    // 80/20 Optimization: Inline eviction if the key is already expired
    if (existing && now > existing.resetTime) {
      this.store.delete(key);
    }

    let record: RateLimitRecord;

    if (existing && now <= existing.resetTime) {
      record = {
        hits: existing.hits + 1,
        resetTime: existing.resetTime
      };
    } else {
      record = {
        hits: 1,
        resetTime: now + windowMs
      };
      // Track this key's expiration chronologically
      this.expirationQueue.push({ key, resetTime: record.resetTime });
    }

    this.store.set(key, record);

    return { ...record };
  }

  private purge(): void {
    const now = Date.now();

    // Chronological Sweep: Since items are appended in order of expiration time,
    // we only look at the oldest items at the front of our queue.
    // The moment we hit an item that hasn't expired yet, we can STOP instantly.
    while (this.queueIndex < this.expirationQueue.length) {
      const entry = this.expirationQueue[this.queueIndex]!;

      if (entry.resetTime > now) {
        break; // Stop immediately. Everything after this is still valid.
      }

      // Evict from map if the current record matches this expiration mark
      const currentRecord = this.store.get(entry.key);
      if (currentRecord && currentRecord.resetTime <= now) {
        this.store.delete(entry.key);
      }

      this.queueIndex++;
    }

    // Memory clean up for the queue array itself once it grows large
    if (this.queueIndex > 10000) {
      this.expirationQueue.splice(0, this.queueIndex);
      this.queueIndex = 0;
    }
  }
}

blocks/rate-limiter/variants/redis-store.ts

Redis store using an atomic Lua script to increment and set expiry in one round-trip.

import type { Redis } from "ioredis";
import type { RateLimitStore, RateLimitRecord } from "../core/core";

export interface RedisWithRateLimit extends Redis {
  performRateLimitIncrement(key: string, windowMs: number): Promise<[number, number]>;
}
export class RedisStore implements RateLimitStore {
  constructor(
    private redisClient: RedisWithRateLimit,
    private keyPrefix = "rl:"
  ) {
    // Define the atomic rate limit script inside ioredis once on startup
    this.redisClient.defineCommand("performRateLimitIncrement", {
      numberOfKeys: 1,
      lua: `
        local key = KEYS[1]
        local windowMs = tonumber(ARGV[1])

        -- 1. Increment the hit counter
        local hits = redis.call('INCR', key)

        -- 2. If it's a brand new key, immediately anchor its exact millisecond TTL
        if hits == 1 then
            redis.call('PEXPIRE', key, windowMs)
        end

        -- 3. Fetch the exact remaining millisecond TTL
        local pttl = redis.call('PTTL', key)

        return {hits, pttl}
      `
    });
  }

  async increment(key: string, windowMs: number): Promise<RateLimitRecord> {
    const fullKey = `${this.keyPrefix}${key}`;
    const now = Date.now();

    // Execute the atomic script in exactly 1 network round-trip
    // We cast to 'any' because we added a custom dynamic command name above
    const [hits, pttl] = (await this.redisClient.performRateLimitIncrement(fullKey, windowMs)) as [
      number,
      number
    ];

    // Handle defensive fallback if PTTL returns a negative error code
    const actualRemainingMs = pttl > 0 ? pttl : windowMs;

    return {
      hits,
      resetTime: now + actualRemainingMs
    };
  }
}

Configuration

RateLimitConfig

OptionTypeDefaultDescription
windowMsnumber60000Time window in milliseconds
maxnumber100Maximum requests allowed within the window
statusCodenumber429HTTP status code returned when rate limited
messagestring | Record<string, unknown>{ error: "Too many requests, please try again later." }Response body when rate limited
standardHeadersbooleantrueEmit RFC-compliant RateLimit-* headers
legacyHeadersbooleantrueEmit X-RateLimit-* headers for backward compatibility

Adapter Options

Each adapter accepts all RateLimitConfig fields plus:

OptionTypeDefaultDescription
storeRateLimitStoreRequiredStorage backend implementation
keyGenerator(req) => stringClient IP (getClientIp)Function that returns a unique client identifier

The keyGenerator parameter type varies by adapter:

  • Express: (req: Request) => string
  • Fastify: (req: FastifyRequest) => string
  • Hono: (c: Context) => string

RedisStore

OptionTypeDefaultDescription
redisClientRedisWithRateLimitRequiredioredis client instance with the performRateLimitIncrement command defined
keyPrefixstring"rl:"Prefix prepended to all Redis keys

The MemoryStore is suitable for single-instance deployments only. Each server instance maintains its own independent counter. Use RedisStore when you need a shared rate limit across multiple instances.


Architecture

Request


Adapter (Express / Fastify / Hono)
   │  1. Generate client key via keyGenerator
   │  2. Call evaluateRateLimit(key, store, config)

evaluateRateLimit (core engine)
   │  1. store.increment(key, windowMs) → { hits, resetTime }
   │  2. Compute remaining quota and retry-after seconds
   │  3. Build standard + legacy headers
   │  4. Return { isBlocked, statusCode, message, headers }

Adapter
   │  1. Apply headers to response
   │  2. If blocked → return 429 with message
   │  3. If allowed → pass through to next handler
   │  4. If store throws → log error, pass through (fail-open)

The adapter extracts a client identifier (default: IP address), calls evaluateRateLimit with the key, store, and config, then applies the returned headers to the response. When the client exceeds the limit, the adapter returns a 429 Too Many Requests response with a Retry-After header. If the store throws an error, the adapter logs the failure and lets the request through — a store outage never blocks legitimate traffic.

The MemoryStore uses an in-memory Map with a chronological expiration queue. Expired entries are purged incrementally every 60 seconds, stopping at the first non-expired entry for O(1) average-case cleanup. The RedisStore uses a single atomic Lua script (INCR + PEXPIRE + PTTL) to increment the counter and read the remaining TTL in one network round-trip.


When to Use

  • You need rate limiting across Express, Fastify, or Hono from a shared core engine.
  • You want to swap between in-memory and Redis storage without changing application code.
  • You want generated source code you own and can modify over an opaque npm dependency.
  • You need RFC-compliant RateLimit-* headers alongside legacy X-RateLimit-* headers.

When Not to Use

  • You only use Express and prefer a zero-config solution like express-rate-limit.
  • You do not need pluggable storage or cross-framework compatibility.

Usage

Express

import express from "express";
import { expressRateLimit } from "@/blocks/rate-limiter/adapters/express";
import { MemoryStore } from "@/blocks/rate-limiter/variants/memory-store";

const app = express();

// Apply rate limiting to all /api routes — 100 requests per minute
app.use(
  "/api",
  expressRateLimit({
    windowMs: 60_000,
    max: 100,
    store: new MemoryStore()
  })
);

MemoryStore is for single-instance deployments only. Each server instance maintains its own independent counter. Use RedisStore for shared rate limits across multiple instances.

Fastify

import Fastify from "fastify";
import { fastifyRateLimit } from "@/blocks/rate-limiter/adapters/fastify";
import { MemoryStore } from "@/blocks/rate-limiter/variants/memory-store";

const fastify = Fastify();

// onRequest hook runs before route handlers — 100 requests per minute
fastify.addHook(
  "onRequest",
  fastifyRateLimit({
    windowMs: 60_000,
    max: 100,
    store: new MemoryStore()
  })
);

Hono

import { Hono } from "hono";
import { honoRateLimit } from "@/blocks/rate-limiter/adapters/hono";
import { MemoryStore } from "@/blocks/rate-limiter/variants/memory-store";

const app = new Hono();

// Apply to all routes — 100 requests per minute
app.use(
  "*",
  honoRateLimit({
    windowMs: 60_000,
    max: 100,
    store: new MemoryStore()
  })
);

Custom Key Generator

Use the authenticated user's ID instead of IP to rate-limit per account:

import { expressRateLimit } from "@/blocks/rate-limiter/adapters/express";
import { MemoryStore } from "@/blocks/rate-limiter/variants/memory-store";

app.use(
  "/api",
  expressRateLimit({
    windowMs: 60_000,
    max: 100,
    store: new MemoryStore(),
    // Rate limit by user ID instead of IP — prevents shared-IP false positives
    keyGenerator(req) {
      return req.user?.id ?? "anonymous";
    }
  })
);

Production Usage with Redis

Shared rate limit across multiple server instances:

import Redis from "ioredis";
import { expressRateLimit } from "@/blocks/rate-limiter/adapters/express";
import { RedisStore } from "@/blocks/rate-limiter/variants/redis-store";

const redis = new Redis(process.env.REDIS_URL!);

app.use(
  "/api",
  expressRateLimit({
    windowMs: 60_000,
    max: 100,
    // Atomic Lua script — increment + expire in one round-trip
    store: new RedisStore(redis)
  })
);

Custom Store

Implement the RateLimitStore interface to plug in any backing store:

import type { RateLimitStore, RateLimitRecord } from "@/blocks/rate-limiter/core/core";

class PostgresStore implements RateLimitStore {
  async increment(key: string, windowMs: number): Promise<RateLimitRecord> {
    const result = await db.query(
      `INSERT INTO rate_limits (key, hits, reset_time)
       VALUES ($1, 1, NOW() + $2::interval)
       ON CONFLICT (key) DO UPDATE SET hits = rate_limits.hits + 1
       RETURNING hits, reset_time`,
      [key, `${windowMs} milliseconds`]
    );
    return {
      hits: result.rows[0].hits,
      resetTime: new Date(result.rows[0].reset_time).getTime()
    };
  }
}

API Reference

evaluateRateLimit

Core engine that checks and increments the rate limit for a given key.

async function evaluateRateLimit(
  key: string,
  store: RateLimitStore,
  config: RateLimitConfig
): Promise<RateLimitResult>;
ParameterTypeRequiredDescription
keystringYesUnique client identifier
storeRateLimitStoreYesStorage backend to persist counters
configRateLimitConfigYesRate limit configuration

ReturnsRateLimitResult with isBlocked, statusCode, message, and headers.

expressRateLimit

Creates Express middleware for rate limiting.

function expressRateLimit(
  options: ExpressOptions
): (req: Request, res: Response, next: NextFunction) => Promise<void>;
ParameterTypeRequiredDescription
optionsExpressOptionsYesConfiguration with required store field

fastifyRateLimit

Creates a Fastify onRequest hook for rate limiting.

function fastifyRateLimit(
  options: FastifyOptions
): (request: FastifyRequest, reply: FastifyReply) => Promise<void>;
ParameterTypeRequiredDescription
optionsFastifyOptionsYesConfiguration with required store field

honoRateLimit

Creates Hono middleware for rate limiting.

function honoRateLimit(options: HonoOptions): MiddlewareHandler;
ParameterTypeRequiredDescription
optionsHonoOptionsYesConfiguration with required store field

getClientIp

Extracts the client IP from request headers. Checks x-forwarded-for, x-real-ip, then falls back to remoteAddress.

function getClientIp(
  headers: Record<string, string | string[] | undefined>,
  remoteAddress?: string
): string;
ParameterTypeRequiredDescription
headersRecord<string, string | string[] | undefined>YesRequest headers object
remoteAddressstringNoSocket remote address fallback

Returns — The first IP from x-forwarded-for, or x-real-ip, or remoteAddress, or "unknown-ip".

MemoryStore

In-memory store using a Map with a chronological expiration queue and bounded incremental purge every 60 seconds.

class MemoryStore implements RateLimitStore {
  increment(key: string, windowMs: number): RateLimitRecord;
}

RedisStore

Redis store using an atomic Lua script to increment and set expiry in one network round-trip.

class RedisStore implements RateLimitStore {
  constructor(redisClient: RedisWithRateLimit, keyPrefix?: string);
  increment(key: string, windowMs: number): Promise<RateLimitRecord>;
}

RateLimitConfig

interface RateLimitConfig {
  windowMs: number;
  max: number;
  statusCode: number;
  message: string | Record<string, unknown>;
  standardHeaders: boolean;
  legacyHeaders: boolean;
}

RateLimitStore

interface RateLimitStore {
  increment(key: string, windowMs: number): Promise<RateLimitRecord> | RateLimitRecord;
}

RateLimitRecord

interface RateLimitRecord {
  hits: number;
  resetTime: number; // Unix timestamp in ms
}

RateLimitResult

interface RateLimitResult {
  isBlocked: boolean;
  statusCode: number;
  message: string | Record<string, unknown>;
  headers: Record<string, string>;
}

Examples

Basic Usage

Minimal setup — 100 requests per minute with in-memory storage:

import { expressRateLimit } from "@/blocks/rate-limiter/adapters/express";
import { MemoryStore } from "@/blocks/rate-limiter/variants/memory-store";

app.use(
  expressRateLimit({
    store: new MemoryStore(),
    max: 100,
    windowMs: 60_000
  })
);

Production Usage with Redis

Shared counter across instances with Redis:

import Redis from "ioredis";
import { expressRateLimit } from "@/blocks/rate-limiter/adapters/express";
import { RedisStore } from "@/blocks/rate-limiter/variants/redis-store";

const redis = new Redis(process.env.REDIS_URL!);

app.use(
  "/api",
  expressRateLimit({
    max: 100,
    windowMs: 60_000,
    store: new RedisStore(redis)
  })
);

Per-User Rate Limiting

Use an authenticated user ID as the key instead of IP:

expressRateLimit({
  max: 50,
  windowMs: 60_000,
  store: new MemoryStore(),
  keyGenerator(req) {
    return req.user.id; // Rate limit per account, not per IP
  }
});

Testing

Unit-test rate limiting by mocking the store:

import { evaluateRateLimit } from "@/blocks/rate-limiter/core/core";
import type { RateLimitStore, RateLimitRecord } from "@/blocks/rate-limiter/core/core";

// Mock store that returns a specific hit count
function createMockStore(hits: number): RateLimitStore {
  return {
    increment: () => ({
      hits,
      resetTime: Date.now() + 60_000
    })
  };
}

const config = {
  windowMs: 60_000,
  max: 100,
  statusCode: 429,
  message: { error: "Too many requests" },
  standardHeaders: true,
  legacyHeaders: true
};

// Under limit — request passes through
const allowed = await evaluateRateLimit("user-1", createMockStore(50), config);
console.assert(allowed.isBlocked === false);

// Over limit — request blocked with 429
const blocked = await evaluateRateLimit("user-1", createMockStore(101), config);
console.assert(blocked.isBlocked === true);
console.assert(blocked.statusCode === 429);

FAQ

What happens when the store throws an error?

The adapter logs the error and calls next() (or await next()), allowing the request to proceed. This fail-open behavior prevents a store outage from blocking legitimate traffic.

How do the standard and legacy headers differ?

Standard headers (RateLimit-*) follow the RFC 9421 draft. RateLimit-Reset contains the remaining seconds in the window. Legacy headers (X-RateLimit-*) match the de facto industry standard (GitHub, Twitter). X-RateLimit-Reset contains an absolute Unix epoch timestamp in seconds. Both are enabled by default.

What does the RateLimit-Limit header format look like?

The standard header uses the format 100, window=60 — the max count followed by the window duration in seconds.

Can I use both standard and legacy headers?

Yes. Both are enabled by default. Toggle them independently with standardHeaders and legacyHeaders.

How does the MemoryStore handle expiration?

Expired entries are purged incrementally every 60 seconds. The store maintains a chronological queue of expiration times. During purge, it walks from the oldest entry and stops at the first non-expired item — so cleanup is O(1) amortized, not a full map scan.

How does the RedisStore stay atomic?

A single Lua script executes INCR, PEXPIRE (on first hit), and PTTL in one round-trip. This prevents race conditions where two concurrent requests could both see hits === 1 and double-set the expiry.

On this page