@systemix/rate-limit

Rate limiting with fixed-window, sliding-window, and token-bucket algorithms, a pluggable store, and Express-compatible middleware. Zero dependencies.

Installation

ts
pnpm add @systemix/rate-limit

Basic Usage

ts
import { createRateLimiter } from '@systemix/rate-limit';

const limiter = createRateLimiter({ limit: 5, windowMs: 60_000 });

const result = await limiter.limit('user:42');
// → { success: true, limit: 5, remaining: 4, reset: 1700000000000, retryAfter: 0 }

if (!result.success) {
  console.log(`Retry in ${result.retryAfter}ms`);
}

Algorithms

AlgorithmBehaviorBest for
fixed-windowCounter resets every window — default, lowest memorySimple request caps
sliding-windowRolling log of timestamps — no boundary burstsFair, smooth quotas
token-bucketContinuous refill — tolerates short burstsAPIs with bursty traffic

Express Middleware

Dependency-free: works with Express, Next.js route handlers, and plain http via structural types. Emits standard RateLimit-* and Retry-After headers.

ts
import { expressRateLimit } from '@systemix/rate-limit';

app.use(
  '/api',
  expressRateLimit({
    limit: 100,
    windowMs: 60_000,
    statusCode: 429,
    message: { error: 'Slow down!' },
    keyGenerator: (req) => req.ip ?? 'unknown',
  }),
);

Examples

Generic function wrapper

ts
import { createRateLimiter, withRateLimit } from '@systemix/rate-limit';

const limiter = createRateLimiter({ limit: 10, windowMs: 60_000 });

const getUser = withRateLimit(fetchUser, limiter, (userId) => userId);
// throws RateLimitExceededError when the limit is hit

Custom store (shared across processes)

The default in-memory store is atomic only inside one process. To limit across several servers, implement RateLimitStore against a shared system. The store owns the algorithm step, so it can run it atomically where the data lives (for example one Redis Lua script per hit) instead of a racy read-modify-write over the network:

ts
import type { RateLimitStore } from '@systemix/rate-limit';

class SharedStore implements RateLimitStore {
  async hit(key, policy, now) {
    /* atomically apply policy.algorithm; return a RateLimitResult */
  }
  async peek(key, policy, now) {
    /* same verdict, no state change */
  }
  async delete(key) {}
  async clear() {}
  async sweep(now) {} // optional
}

Requirements: hit must be atomic per key (concurrent hits can never both pass the limit), peek must not mutate, and idle keys should expire so memory follows active keys. The in-memory store is the reference implementation. A Redis store is not included yet.

Memory bounds

The in-memory store sweeps idle entries on its own and caps the number of keys (new InMemoryStore({ maxKeys: 100_000, sweepIntervalMs: 60_000 })). Keys usually come from clients, so an unbounded map is a denial-of-service risk. When the cap is hit, idle entries go first, then the oldest keys — an evicted client starts over with a fresh allowance.

Response headers

ts
import { rateLimitHeaders } from '@systemix/rate-limit';

const result = await limiter.limit(key);
const headers = rateLimitHeaders(result, limiter.options, 'legacy'); // or 'draft' | 'both'
// legacy: RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset (seconds until reset), Retry-After when blocked
// draft:  RateLimit-Policy: "default";q=100;w=60   RateLimit: "default";r=99;t=31

RateLimit-Reset is seconds until reset, not an epoch timestamp. The draft format follows draft-ietf-httpapi-ratelimit-headers, which is still an Internet-Draft and may change.

Client IPs

expressRateLimit keys on req.ip by default, grouping IPv6 addresses by /64 (one subscriber usually owns a whole /64, so keying on the full address would give them 2^64 allowances). X-Forwarded-For is not read: clients can forge it. Behind a proxy, configure your framework's proxy trust so req.ip is correct, or pass a keyGenerator. Use normalizeIp(ip) in your own key generators.

Other limiter methods (all async): peek(key) (no hit recorded), resetKey(key), resetAll(), and sweep().

Try it

Open the Rate Limiter demo to send requests until you get blocked — then watch it recover.