Skip to content

rate-limit

Enforces per-client request rate limits using a sliding-window counter. Injects X-RateLimit-* response headers on every request. On limited requests, also returns 429 Too Many Requests with a Retry-After value.

The store is pluggable — any object implementing hit(key, windowMs) → {count, resetMs, resetAt} can replace the built-in MemoryStore for Redis-backed or distributed rate limiting. resetAt is the absolute window-reset time in milliseconds in the store’s own clock domain (so injectable clocks on MemoryStore produce correct X-RateLimit-Reset headers).

Pipeline stage: Negotiation

import {rateLimit} from '@centralping/ergo';
Option Type Default Description
max number 100 Maximum requests per window
windowMs number 60_000 (1 min) Window duration in milliseconds
store object MemoryStore Pluggable store with hit(key, windowMs) → {count, resetMs, resetAt}
keyGenerator function defaultKeyGenerator (req) => string — client identifier (default: req.socket.remoteAddress)
{
response: {
headers: [
['X-RateLimit-Limit', '100'],
['X-RateLimit-Remaining', '99'],
['X-RateLimit-Reset', '1717041600']
]
}
}
{
response: {
statusCode: 429,
retryAfter: 30,
headers: [
['X-RateLimit-Limit', '100'],
['X-RateLimit-Remaining', '0'],
['X-RateLimit-Reset', '1717041600']
]
}
}

send() automatically sets the Retry-After header and formats the RFC 9457 error body.

Status Condition
429 Too Many Requests Request count exceeds max within windowMs
import {compose, rateLimit} from '@centralping/ergo';
const pipeline = compose(
rateLimit({max: 100, windowMs: 60_000}),
(req, res, acc) => ({
response: {statusCode: 200, body: {ok: true}},
}),
);

The store option accepts any object that implements a single method:

Method Signature Returns
hit (key: string, windowMs: number) => object {count: number, resetMs: number, resetAt: number}
Return Field Type Meaning
count number Total hits recorded for key within the current window
resetMs number Milliseconds until the oldest entry in the window expires
resetAt number Absolute reset time in milliseconds in the store’s clock domain

The middleware calls store.hit(key, windowMs) once per request. The store is responsible for recording the hit, pruning expired entries, and returning the current state. No other methods are called.

The built-in MemoryStore accepts optional configuration:

Option Type Default Description
maxKeys number 10_000 Maximum tracked keys before FIFO eviction. When exceeded, the oldest key is silently removed.
now function Date.now Clock function returning current time in milliseconds. Useful for deterministic testing.
import {MemoryStore} from '@centralping/ergo/lib/rate-limit';
const store = new MemoryStore({maxKeys: 50_000});
Method Returns Description
hit(key, windowMs) {count, resetMs, resetAt} Record a hit and return current window state
reset() void Clear all tracked keys, restoring the store to its initial state

reset() is useful for integration tests that share a single store instance across multiple test cases. Call it between tests to prevent rate-limit counter accumulation:

import {describe, beforeEach} from 'node:test';
import {MemoryStore} from '@centralping/ergo/lib/rate-limit';
const store = new MemoryStore();
describe('my API', () => {
beforeEach(() => store.reset());
// tests can now hit the rate-limited endpoint without budget exhaustion
});

For multi-instance deployments, implement the hit() contract with a shared data store. Because store.hit() must return synchronously, a Redis-backed store uses a local sliding-window counter for the synchronous return value and syncs counts to Redis in the background for cross-instance visibility. Bound the local map with maxKeys FIFO eviction (same default as MemoryStore) so high-cardinality keys cannot grow the process heap unboundedly.

This example uses ioredis with sorted sets:

import Redis from 'ioredis';
class RedisStore {
#local = new Map();
#maxKeys;
constructor(redis, {maxKeys = 10_000} = {}) {
if (!Number.isInteger(maxKeys) || maxKeys < 1) {
throw new TypeError('maxKeys must be a positive integer');
}
this.redis = redis;
this.#maxKeys = maxKeys;
}
hit(key, windowMs) {
const now = Date.now();
const cutoff = now - windowMs;
const windowKey = `rl:${key}`;
let entries = this.#local.get(windowKey);
if (!entries) {
entries = [];
this.#local.set(windowKey, entries);
}
while (entries.length > 0 && entries[0] <= cutoff) entries.shift();
// Delete-then-set refreshes Map insertion order for FIFO eviction
if (entries.length === 0) {
this.#local.delete(windowKey);
entries = [now];
this.#local.set(windowKey, entries);
} else {
entries.push(now);
}
// FIFO eviction — Map insertion order; silent like MemoryStore
if (this.#local.size > this.#maxKeys) {
const oldest = this.#local.keys().next().value;
this.#local.delete(oldest);
}
const count = entries.length;
const resetMs = entries.length > 0
? Math.max(0, entries[0] + windowMs - now)
: windowMs;
const resetAt = now + resetMs;
this.#sync(windowKey, now, cutoff, windowMs);
return {count, resetMs, resetAt};
}
#sync(windowKey, now, cutoff, windowMs) {
const pipeline = this.redis.pipeline();
pipeline.zremrangebyscore(windowKey, 0, cutoff);
pipeline.zadd(windowKey, now, `${now}:${Math.random()}`);
pipeline.pexpire(windowKey, windowMs);
pipeline.exec().catch(() => {});
}
}
Scenario Recommended Store
Single process, development, or testing MemoryStore (default) — no external dependencies
Multiple instances behind a load balancer Redis or another shared store — counters must be visible across instances
Serverless / ephemeral containers Shared store — process restarts reset in-memory counters

When deploying behind a reverse proxy, provide a custom keyGenerator that reads the forwarded client IP:

rateLimit({
keyGenerator: (req) => {
const forwarded = req.headers['x-forwarded-for'];
return forwarded
? forwarded.split(',')[0].trim()
: req.socket.remoteAddress;
},
})

This applies to both pipeline-level rate limiting (defaults.rateLimit or per-route rateLimit in ergo-router) and transport-level rate limiting (transport.rateLimit). The trustProxy router transport option does not automatically make rate limiting proxy-aware — it only affects request IDs and HSTS. A custom keyGenerator is always required for correct per-client rate limiting behind a proxy.

Per-route rateLimit config follows Config Resolution semantics:

Config Level Behavior
Route rateLimit: {max, windowMs} Overrides defaults for this route (shallow replace)
Route rateLimit: false Disables rate limiting for this route
Route rateLimit: undefined (omitted) Inherits from defaults.rateLimit
defaults.rateLimit: {max, windowMs} Applied to every route that does not override
defaults.rateLimit: undefined (omitted) No pipeline-level rate limiting

Only presets.public includes transport-level rate limiting (transport.rateLimit: {} with built-in defaults of 100 requests per 60 seconds). The jsonApi, sse, and webhooks presets do not include rate limiting at either level.

To add pipeline-level rate limiting when using a preset:

import createRouter, {presets} from '@centralping/ergo-router';
const router = createRouter({
...presets.jsonApi,
defaults: {
...presets.jsonApi.defaults,
rateLimit: {max: 200, windowMs: 60_000},
},
});
  • Sub-Routers — Per-group rate limiting with transport vs pipeline distinction
  • Testing Patterns — Rate-limit assertion patterns and store isolation
  • Production Deployment — Redis rate limiting in a Docker deployment with nginx and health checks

See the auto-generated rateLimit API docs.