@systemix/rate-limit
Rate limiting with fixed-window, sliding-window, and token-bucket algorithms, a pluggable store, and Express-compatible middleware. Zero dependencies.
Installation
pnpm add @systemix/rate-limit
Basic Usage
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
| Algorithm | Behavior | Best for |
|---|---|---|
fixed-window | Counter resets every window — default, lowest memory | Simple request caps |
sliding-window | Rolling log of timestamps — no boundary bursts | Fair, smooth quotas |
token-bucket | Continuous refill — tolerates short bursts | APIs 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.
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
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:
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
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.