RateLimiter

Request rate limiting, as a ready-made Middleware.

RateLimiter counts requests per caller within a rolling window and rejects anything over the limit with Status.TOO_MANY_REQUESTS. Who "the caller" is depends on what the request proves about itself: an authenticated request is identified by its token, an unauthenticated one by its IP, and one with neither by a fingerprint of its headers. Each tier gets its own limit, since they differ in how accountable and how forgeable they are.

Identifiers are hashed with a rotating salt before being stored, so the limiter never holds a token or an IP in memory.

import { RateLimiter } from "@ozanarslan/corpus";

new RateLimiter({ windowMs: 60_000, limits: { authenticated: 120, ipBased: 60, fingerprint: 20 } });

Counting is backed by RateLimiterStoreInterface; the default RateLimiterMemoryStore is per-process, so a multi-instance deployment wants a shared store instead.

Contents
  1. RateLimiter
  2. RateLimiterMemoryStore
  3. RateLimiterStoreInterface
  4. RateLimiterConfig

RateLimiter

class

class RateLimiter extends Middleware

A Middleware that limits how often a caller may make requests.

Every response carries the limit, the remaining allowance and the reset time, and those header names are added to HeaderKey.AccessControlExposeHeaders so browser clients can actually read them. Exceeding the limit throws an Exception carrying the response built so far, so the rejection keeps its rate limit headers.

RateLimiter.constructor()

constructor(config: Partial<RateLimiterConfig>= {})

Creates a limiter and registers it on the nearest App.

It targets the routes registered so far, minus RouteVariant.bundle ones — a single page load pulls dozens of assets, which would exhaust any sensible limit. Construct it after your routes; routes registered later are not covered.

Parameters

RateLimiter.handler

override handler: MiddlewareHandler

Counts the request and either continues the chain or rejects it.

Parameters

Throws — Exception with Status.TOO_MANY_REQUESTS when the limit is exceeded. The current Res is passed as the exception data, so the rejection carries the rate limit headers already set on it.

RateLimiter.getResult()

async getResult(reqHeaders: Headers, resHeaders: Headers): Promise<boolean>

Counts one request against its caller's allowance and writes the rate limit headers.

A window is created on the first request and reused until RateLimiterEntry.resetAt passes, at which point the counter starts over — a fixed window, not a sliding one. The hit is counted whether or not it is allowed, so a caller that keeps hammering a closed window stays closed until it resets.

Exposed separately from RateLimiter.handler so the same accounting can be driven from outside a request chain, in tests or a custom handler.

Parameters

Returns — true when the request is within the limit.

RateLimiter.config

protected readonly config: RateLimiterConfig

The merged settings this limiter runs with.

RateLimiter.store

protected readonly store: RateLimiterStoreInterface

Where counters are kept. The configured store, or a RateLimiterMemoryStore.

RateLimiter.storedSalt

protected storedSalt: string

The current hashing salt. Rotated by RateLimiter.salt.

RateLimiter.saltRotatesAt

protected saltRotatesAt: number

Unix milliseconds at which RateLimiter.storedSalt is replaced.

RateLimiter.getIdAndLimit()

protected getIdAndLimit(headers: Headers): Tuple<string, number>

Identifies the caller and picks the limit that applies to it.

Three tiers are tried in order of how much the request proves about itself. A bearer token of plausible length identifies an authenticated caller and earns the highest limit. Failing that, a valid address from the proxy headers earns the IP limit — shared by everyone behind a NAT, hence lower. Failing that, a fingerprint of the user agent and accept headers earns the lowest limit, since it is trivially forgeable.

Tokens are hashed without the salt so a caller keeps one bucket across rotations; addresses and fingerprints are salted, so they cannot be correlated across rotation windows.

The prefixes (u:, i:, f:) keep the tiers in separate namespaces, so a collision across tiers is impossible.

Parameters

Returns — A Tuple of the hashed identifier and the applicable limit.

RateLimiter.salt()

protected salt(): string

Returns the current hashing salt, rotating it when it has expired.

Rotation is lazy rather than scheduled, so no timer is held open. A rotation changes every derived identifier at once, which resets the affected counters — acceptable at a daily cadence, and the point: it bounds how long any caller can be tracked.

Returns — The salt to mix into address and fingerprint hashes.

RateLimiter.maybeCleanStore()

protected async maybeCleanStore(): Promise<void>

Decides whether to sweep expired entries before handling a request.

Cleanup runs on a small random fraction of requests, so the cost is spread out instead of landing on a timer, and unconditionally once the store passes RateLimiterConfig.maxStoreSize, which bounds memory under a flood of one-off callers.

RateLimiter.cleanStore()

protected async cleanStore(): Promise<number>

Removes every entry whose window has ended.

Returns — How many entries remain.

RateLimiter.clearStore()

async clearStore(): Promise<void>

Clears every counter, expired or not, resetting all callers to a full allowance. Mainly useful between tests.

RateLimiter.getStoreSize()

async getStoreSize(): Promise<number>

Returns — How many counters are currently held, expired ones included until the next cleanup.

RateLimiterMemoryStore

class

class RateLimiterMemoryStore implements RateLimiterStoreInterface

In-process counter storage, used when no store is configured.

Writes are serialised per identifier through a promise lock, so concurrent requests from the same caller cannot interleave their read-modify-write and lose a hit.

State lives in one process's memory, so it is lost on restart and not shared between instances — behind a load balancer, each instance enforces the limit separately. Use a shared RateLimiterStoreInterface when that matters.

RateLimiterMemoryStore.map

protected readonly map

The counters, keyed by hashed identifier.

RateLimiterMemoryStore.locks

protected readonly locks

In-flight write locks, keyed by identifier. Present only while a write is running.

RateLimiterMemoryStore.get()

get(id: string): RateLimiterEntry | undefined

Reads a caller's entry.

Parameters

Returns — The entry, or undefined when absent. Expired entries are returned as-is; the limiter checks RateLimiterEntry.resetAt itself.

RateLimiterMemoryStore.set()

async set(id: string, entry: RateLimiterEntry): Promise<void>

Writes a caller's entry, waiting for any write already in progress for the same identifier.

Parameters

RateLimiterMemoryStore.delete()

delete(id: string): void

Removes a caller's entry.

Parameters

RateLimiterMemoryStore.cleanup()

cleanup(now: number): void

Removes every entry whose window has already ended.

Parameters

RateLimiterMemoryStore.clear()

clear(): void

Removes every entry, resetting all counters.

RateLimiterMemoryStore.size()

size(): number

Returns — How many entries are currently held.

RateLimiterStoreInterface

interface

interface RateLimiterStoreInterface

The storage contract for rate limit counters.

Implement it to back the limiter with Redis or anything else shared across processes — the default RateLimiterMemoryStore only counts within one. Every method may be synchronous or asynchronous; the limiter awaits either.

RateLimiterStoreInterface.get()

get(id: string): MaybePromise<RateLimiterEntry | undefined>

Reads a caller's entry.

Parameters

Returns — The entry, or undefined when the caller has none.

RateLimiterStoreInterface.set()

set(id: string, entry: RateLimiterEntry): MaybePromise<void>

Writes a caller's entry, replacing any existing one.

Parameters

RateLimiterStoreInterface.delete()

delete(id: string): MaybePromise<void>

Removes a caller's entry.

Parameters

RateLimiterStoreInterface.cleanup()

cleanup(now: number): MaybePromise<void>

Removes every entry whose window has ended.

Parameters

RateLimiterStoreInterface.clear()

clear(): MaybePromise<void>

Removes every entry, expired or not.

RateLimiterStoreInterface.size()

size(): MaybePromise<number>

Reports how many entries are held.

Returns — The entry count, used to decide when a cleanup is forced.

RateLimiterConfig

type

type RateLimiterConfig = {
	/** Limits based on identifier type: */ limits: {
		/** Authenticated users — higher limit, accountable identity(e.g., 120 requests)*/ authenticated: number;
		/** IP-based — moderate, may be shared(NAT, proxies)(e.g., 60 requests)*/ ipBased: number;
		/** Fingerprint / anonymous — lowest, least trustworthy(e.g., 20 requests)*/ fingerprint: number;
	};
	/** * You can pass a different header key to check for authenticated users. * "Bearer " string is only sliced for Authorization. * */ authHeader?: OrString<"Authorization">;
	/** Time window in milliseconds during which the rate limit applies(default: 60, 000ms = 1 minute)*/ windowMs: number;
	/** * How often to rotate the salt used for hashing identifiers(default: 24h)* Prevents long-term tracking and adds an extra layer of privacy * */ saltRotateMs: number;
	/** * Probability(0-1)of triggering a cleanup of expired entries on each request * Balances memory usage against performance(default: 0.005 = 0.5%)* */ cleanProbability: number;
	/** * Maximum number of entries before forcing a cleanup * Prevents unbounded memory growth(default: 50, 000)* */ maxStoreSize: number;
	/** * Bring your own store implementation like redis. * Uses [RateLimiterMemoryStore](#ratelimitermemorystore) by default. * */ store?: RateLimiterStoreInterface;
	/** * Customizable HTTP header names for rate limit information. * Allows integration with different API conventions or frontend expectations. * * @example * // Custom header names(e.g., for legacy systems)* headerNames: { * limit: "X-RateLimit-Limit", * remaining: "X-RateLimit-Remaining", * reset: "X-RateLimit-Reset", * retryAfter: "Retry-After" * } * * @default Uses standard RateLimit-* headers as defined in IETF draft: * - limit: "RateLimit-Limit" * - remaining: "RateLimit-Remaining" * - reset: "RateLimit-Reset" * - retryAfter: "Retry-After" */ headerNames: {
		/** Header name for the maximum allowed requests in the current window */ limit: string;
		/** Header name for the remaining requests in the current window */ remaining: string;
		/** Header name for the timestamp(Unix seconds)when the window resets */ reset: string;
		/** Header name for seconds to wait before retrying when rate limited */ retryAfter: string;
	};
};

The limiter's settings. RateLimiter merges what you pass over defaultConfig, so every field is optional at the call site.

RateLimiterConfig.limits

limits: {
	/** Authenticated users — higher limit, accountable identity(e.g., 120 requests)*/ authenticated: number;
	/** IP-based — moderate, may be shared(NAT, proxies)(e.g., 60 requests)*/ ipBased: number;
	/** Fingerprint / anonymous — lowest, least trustworthy(e.g., 20 requests)*/ fingerprint: number;
}

Limits based on identifier type:

RateLimiterConfig.authHeader

authHeader?: OrString<"Authorization">

You can pass a different header key to check for authenticated users. "Bearer " string is only sliced for Authorization.

RateLimiterConfig.windowMs

windowMs: number;

Time window in milliseconds during which the rate limit applies (default: 60,000ms = 1 minute)

RateLimiterConfig.saltRotateMs

saltRotateMs: number;

How often to rotate the salt used for hashing identifiers (default: 24h) Prevents long-term tracking and adds an extra layer of privacy

RateLimiterConfig.cleanProbability

cleanProbability: number;

Probability (0-1) of triggering a cleanup of expired entries on each request Balances memory usage against performance (default: 0.005 = 0.5%)

RateLimiterConfig.maxStoreSize

maxStoreSize: number;

Maximum number of entries before forcing a cleanup Prevents unbounded memory growth (default: 50,000)

RateLimiterConfig.store

store?: RateLimiterStoreInterface

Bring your own store implementation like redis. Uses RateLimiterMemoryStore by default.

RateLimiterConfig.headerNames

headerNames: {
	/** Header name for the maximum allowed requests in the current window */ limit: string;
	/** Header name for the remaining requests in the current window */ remaining: string;
	/** Header name for the timestamp(Unix seconds)when the window resets */ reset: string;
	/** Header name for seconds to wait before retrying when rate limited */ retryAfter: string;
}

Customizable HTTP header names for rate limit information. Allows integration with different API conventions or frontend expectations.

Default — Uses standard RateLimit-* headers as defined in IETF draft: - limit: "RateLimit-Limit" - remaining: "RateLimit-Remaining" - reset: "RateLimit-Reset" - retryAfter: "Retry-After"

// Custom header names (e.g., for legacy systems)
headerNames: {
  limit: "X-RateLimit-Limit",
  remaining: "X-RateLimit-Remaining",
  reset: "X-RateLimit-Reset",
  retryAfter: "Retry-After"
}