Comprehensive, framework-agnostic TypeScript rate limiting.
7 algorithms · 6 stores · 18 framework adapters · Functional composition API
Quick Start · Algorithms · Stores · Adapters · Composition · Benchmarks · Docs
- Features
- How throtto Compares
- Installation
- Quick Start
- Algorithms
- Storage Adapters
- Framework Adapters
- Composition
- HTTP Utilities
- Analytics
- Decorators
- Testing
- Admin & Operations
- TypeScript
- Key Normalization
- Fail Modes
- Benchmarks
- Bundle Size
- Peer Dependencies
- Contributing
- Documentation
- 🔒 7 algorithms - Fixed Window, Sliding Window (Counter + Log), Token Bucket, Leaky Bucket, GCRA, Concurrency
- 🏪 7 storage adapters - Memory, Cluster (IPC), Redis, Upstash, PostgreSQL, MySQL, SQLite
- 🔌 18 framework adapters - Express, Fastify, Hono, Next.js, SvelteKit, Remix, Astro, NestJS, Elysia, H3, tRPC, WebSocket, Koa, Lambda, CloudFlare Workers, Bun, Deno, Generic HTTP
- 🎯 Functional composition -
pipe()limiters, wrappers, and patterns like building blocks - 🛡️ Production-grade - allowlists, dry-run, overrides, thresholds, backpressure, penalty box, graceful shutdown
- 🧪 Testing-first - controllable clocks, mock stores, assertion helpers, one-liner
createTestLimiter - 📋 Standards-compliant - RFC 9309 (draft-7) headers, RFC 7807 error bodies
- 📊 Built-in analytics - Prometheus, JSON, CSV export, event streaming
- 📝 Zero runtime dependencies in core
- ⚡ Tree-shakeable, ESM-only, full TypeScript with strict types
Compared against the 6 most popular npm rate limiting packages. ✅ = built-in,
Basics
| Feature | throtto | rate-limiter-flexible | express-rate-limit | @upstash/ratelimit | @nestjs/throttler | bottleneck | limiter |
|---|---|---|---|---|---|---|---|
| Downloads/wk | new | 1.9M | 38.6M | 1.5M | 2.5M | 7.9M | 10.9M |
| Production-proven | ❌ new | ✅ 5+ years | ✅ 10+ years | ✅ | ✅ | ✅ | ✅ |
| Framework-agnostic | ✅ | ✅ | ✅ | ✅ | ✅ | ||
| TypeScript-first | ✅ strict | ✅ | ✅ | ✅ | ✅ | ||
| Zero runtime deps | ✅ | ✅ | ✅ | ✅ | ✅ | ||
| ESM + tree-shake | ✅ | ✅ | ✅ | ✅ | ✅ | ||
| CJS support | ❌ ESM-only | ✅ | ✅ | ✅ | ✅ | ✅ |
Algorithms
| Feature | throtto | rate-limiter-flexible | express-rate-limit | @upstash/ratelimit | @nestjs/throttler | bottleneck | limiter |
|---|---|---|---|---|---|---|---|
| Fixed Window | ✅ | ✅ | ✅ | ✅ | ✅ | ||
| Sliding Window | ✅ counter+log | ✅ counter | |||||
| Token Bucket | ✅ | ✅ | ✅ | ||||
| Leaky Bucket | ✅ | ||||||
| GCRA | ✅ | ||||||
| Concurrency | ✅ | ✅ |
Storage
| Feature | throtto | rate-limiter-flexible | express-rate-limit | @upstash/ratelimit | @nestjs/throttler | bottleneck | limiter |
|---|---|---|---|---|---|---|---|
| Memory | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | |
| Node cluster (IPC, no Redis) | ✅ | ✅ | |||||
| Redis | ✅ | ✅ | ✅ Upstash only | ✅ | |||
| PostgreSQL | ✅ | ✅ | |||||
| MySQL | ✅ | ✅ | |||||
| SQLite | ✅ | ✅ | |||||
| MongoDB | ✅ | ||||||
| Schema gen (SQL/Drizzle/Prisma) | ✅ |
Framework Adapters
| Feature | throtto | rate-limiter-flexible | express-rate-limit | @upstash/ratelimit | @nestjs/throttler | bottleneck | limiter |
|---|---|---|---|---|---|---|---|
| Express | ✅ | ✅ | ✅ | ||||
| Fastify | ✅ | ✅ | |||||
| Hono | ✅ | ||||||
| Next.js / SvelteKit / Remix | ✅ all three | ||||||
| Lambda / CF Workers | ✅ | ✅ | |||||
| Built-in adapters total | ✅ 18 | ✅ 1 | ✅ 1 | ||||
| Per-endpoint limiting | ✅ | ✅ | ✅ | ✅ | ✅ decorators | ||
| Inline config (no limiter) | ✅ |
API & DX
| Feature | throtto | rate-limiter-flexible | express-rate-limit | @upstash/ratelimit | @nestjs/throttler | bottleneck | limiter |
|---|---|---|---|---|---|---|---|
| Functional composition | ✅ pipe() |
chain() |
|||||
| String presets | ✅ '100/min' |
||||||
| RFC 9309 headers | ✅ draft-7 | ✅ draft-8 | |||||
| RFC 7807 error bodies | ✅ | ||||||
| Key resolvers | ✅ 6 built-in | ✅ keyGenerator |
✅ getTracker |
||||
| Decorators | ✅ @Throttle |
✅ @Throttle |
Advanced Features
| Feature | throtto | rate-limiter-flexible | express-rate-limit | @upstash/ratelimit | @nestjs/throttler | bottleneck | limiter |
|---|---|---|---|---|---|---|---|
| Compound (multi-layer) | ✅ | ✅ Union | ✅ multi defs | ✅ chain() |
|||
| Tiered limits | ✅ | ||||||
| Dynamic per-key | ✅ | ✅ fn limit | |||||
| Allowlist / skip | ✅ | ✅ B&W lists | ✅ skip() |
✅ @Skip |
|||
| Dry-run / shadow | ✅ | ||||||
| Override (force) | ✅ | ✅ block() |
|||||
| Penalty box | ✅ | ✅ penalty() |
✅ blockDuration | ||||
| Backpressure | ✅ | ||||||
| Graceful shutdown | ✅ | ✅ stop() |
|||||
| Analytics / Prometheus | ✅ | ✅ dashboard | |||||
| Fail-open + fallback | ✅ | ✅ insurance | ✅ passOnStoreError | ||||
| Job queuing / priority | ✅ | ||||||
| Cluster mode (multi-process) | ✅ | ✅ | |||||
| Multi-region / edge-native | ✅ | ||||||
| Web dashboard | ✅ |
Testing DX
| Feature | throtto | rate-limiter-flexible | express-rate-limit | @upstash/ratelimit | @nestjs/throttler | bottleneck | limiter |
|---|---|---|---|---|---|---|---|
| Controllable clock | ✅ | ||||||
| Mock store | ✅ | ||||||
| One-liner test setup | ✅ | ||||||
| Assertion helpers | ✅ |
Downloads from npm, August 2026. ✅ = built-in,
⚠️ = partial or via community packages. Blank = not available. Corrections welcome - open an issue.Honest gaps: throtto is new and unproven at scale. It's ESM-only (no CJS), has no MongoDB store, no web dashboard, no multi-region coordination, and no job queuing. If you need battle-tested production stability today, rate-limiter-flexible and express-rate-limit have years of track record. If you want modern DX, TypeScript strictness, and composability - that's where throtto shines.
npm install @tzezar/throtto
# or
pnpm add @tzezar/throtto
# or
yarn add @tzezar/throttoimport { rateLimit } from '@tzezar/throtto'
const limiter = rateLimit('100/minute')
const result = await limiter.check('user-123')
if (result.allowed) {
console.log(`Remaining: ${result.remaining}/${result.limit}`)
} else {
console.log(`Denied. Retry in ${result.retryAfter}ms`)
}import { rateLimit } from '@tzezar/throtto/adapters/express'
app.use(rateLimit({
limit: 100,
window: '1m',
skipPaths: ['/health', '/metrics'],
}))import { rateLimit } from '@tzezar/throtto'
import { redisStore } from '@tzezar/throtto/stores/redis'
const limiter = rateLimit({
limit: 100,
window: '1m',
store: redisStore({ client: myRedisClient }),
})import { rateLimit, pipe, withAllowlist, withDryRun, withOverride } from '@tzezar/throtto'
// Set up overrides before piping
const overridden = withOverride(rateLimit('100/minute'))
overridden.setOverride('vip-user', { action: 'allow' })
overridden.setOverride('abusive-ip', { action: 'deny' })
// Pipe result is a Limiter - override methods aren't visible on it
const limiter = pipe(
overridden,
withAllowlist({ allowlist: ['admin-key', 'internal-service'] }),
withDryRun({ onShadowDeny: (key, result) => console.warn(`Would deny: ${key}`) }),
)That's it. Pick an algorithm, a store, and a framework adapter - everything else is optional.
Works everywhere -
rateLimit()andcreateLimiter()from'@tzezar/throtto'work in any JavaScript/TypeScript runtime (Node.js, Bun, Deno, edge) without a framework adapter. Adapters add framework-specific middleware integration but are never required.
| Algorithm | Best For | Burst Tolerance | Memory |
|---|---|---|---|
| Fixed Window | Simple rate limiting | Boundary spike possible | O(1) |
| Sliding Window Counter | API rate limiting (default) | Low | O(1) |
| Sliding Window Log | Precision limiting | None | O(n) |
| Token Bucket | Burst-tolerant APIs | High | O(1) |
| Leaky Bucket | Smooth output rate | None | O(1) |
| GCRA | Cell-based scheduling | Configurable | O(1) |
| Concurrency | Parallel execution limits | N/A | O(n) |
import { rateLimit } from '@tzezar/throtto'
// Default: sliding-window-counter
const api = rateLimit('100/minute')
// Token bucket - allow bursts
const burst = rateLimit({ limit: 100, window: '1m', algorithm: 'token-bucket' })
// Concurrency - max 5 parallel requests
const concurrent = rateLimit({ limit: 5, window: '1m', algorithm: 'concurrency' })
// All 7: 'fixed-window' | 'sliding-window-counter' | 'sliding-window-log'
// | 'token-bucket' | 'leaky-bucket' | 'gcra' | 'concurrency'// String preset
const limiter = rateLimit('100/minute')
const limiter = rateLimit('1000/hour')
const limiter = rateLimit('10/second')
// Object config
const limiter = rateLimit({
limit: 100,
window: '1m', // '30s', '1h', '1d', or milliseconds
algorithm: 'token-bucket',
store: redisStore({ client }),
normalizeKey: 'lowercase',
failMode: 'open', // allow on store errors (default: 'open')
fallbackStore: memoryStore(),
})String preset format:
'{number}/{duration}'where duration is a named unit (second,minute,hour,day) or any parseable duration (15m,30s,6h,1h30m). Examples:'100/minute','50/30s','1000/6h'.
Use createLimiter when you need algorithm instances with custom parameters, hooks, or a key prefix. It's also the name to use when you import rateLimit from an adapter in the same file:
import { createLimiter, tokenBucket, memoryStore } from '@tzezar/throtto'
const limiter = createLimiter({
algorithm: tokenBucket({ capacity: 20, refillRate: 10, refillInterval: 1000 }),
store: memoryStore(),
prefix: 'api:',
normalizeKey: 'lowercase-trim',
hooks: {
onAllow: (key, result) => { /* ... */ },
onDeny: (key, result) => { /* ... */ },
onError: (key, error) => { /* ... */ },
},
})// Check and consume a token (returns allowed or denied result)
const result = await limiter.check('user-123')
const result = await limiter.check('user-123', { cost: 5 })
// Consume - throws RateLimitExceededError on deny
const allowed = await limiter.consume('user-123')
// Peek - check current state without consuming
const info = await limiter.peek('user-123')
// Reset - clear rate limit state for a key
await limiter.reset('user-123')
// Shutdown - clean up resources
await limiter.shutdown({ timeout: 5000 })| Store | Use Case | Distributed | Persistence |
|---|---|---|---|
| Memory | Development, single-process | No | No |
| Cluster | Node cluster / PM2 on one host | Workers | No |
| Redis | Production, multi-instance | Yes | Optional |
| Upstash | Serverless, edge | Yes | Yes |
| PostgreSQL | Already have Postgres | Yes | Yes |
| MySQL | Already have MySQL | Yes | Yes |
| SQLite | Embedded, single-server | No | Yes |
import { memoryStore } from '@tzezar/throtto/stores/memory'
import { clusterStore } from '@tzezar/throtto/stores/cluster'
import { redisStore } from '@tzezar/throtto/stores/redis'
import { upstashStore } from '@tzezar/throtto/stores/upstash'
import { postgresStore } from '@tzezar/throtto/stores/postgres'
import { mysqlStore } from '@tzezar/throtto/stores/mysql'
import { sqliteStore } from '@tzezar/throtto/stores/sqlite'All stores implement the same Store interface - swap them without changing any other code.
// Health check (all stores)
const isHealthy = await store.ping?.()
// List keys (memory + redis)
const keys = await store.keys?.('api:')
// Clean up expired entries (SQL stores)
await store.cleanup?.()
// Shutdown
await store.shutdown?.()Combine a fast local cache with a distributed store for the best of both worlds:
import { withCache } from '@tzezar/throtto'
import { redisStore } from '@tzezar/throtto/stores/redis'
const store = withCache(redisStore({ client }), {
maxSize: 1000,
ttl: 5000, // local cache TTL in ms
})For SQL stores, generate the required schema in your preferred format:
import { getSchema, getDrizzleSchema, getPrismaSchema } from '@tzezar/throtto/schemas'
const sql = getSchema('postgres') // raw SQL
const drizzle = getDrizzleSchema() // Drizzle ORM
const prisma = getPrismaSchema() // Prisma schema blockOr use the CLI:
npx @tzezar/throtto schema --store postgres --format sql
npx @tzezar/throtto schema --store mysql --format drizzle
npx @tzezar/throtto schema --store sqlite --format prismaEvery adapter returns the appropriate middleware type for its framework. Supports skipPaths, skipMethods, and custom key resolvers.
// All adapters export rateLimit
import { rateLimit } from '@tzezar/throtto/adapters/express'
import { rateLimit } from '@tzezar/throtto/adapters/fastify'
import { rateLimit } from '@tzezar/throtto/adapters/hono'
import { rateLimit } from '@tzezar/throtto/adapters/nextjs'
import { rateLimit } from '@tzezar/throtto/adapters/sveltekit'
import { rateLimit } from '@tzezar/throtto/adapters/remix'
import { rateLimit } from '@tzezar/throtto/adapters/astro'
import { rateLimit } from '@tzezar/throtto/adapters/nestjs'
import { rateLimit } from '@tzezar/throtto/adapters/elysia'
import { rateLimit } from '@tzezar/throtto/adapters/h3'
import { rateLimit } from '@tzezar/throtto/adapters/trpc'
import { rateLimit } from '@tzezar/throtto/adapters/websocket'
import { rateLimit } from '@tzezar/throtto/adapters/koa'
import { rateLimit } from '@tzezar/throtto/adapters/lambda'
import { rateLimit } from '@tzezar/throtto/adapters/cloudflare-workers'
import { rateLimit } from '@tzezar/throtto/adapters/bun'
import { rateLimit } from '@tzezar/throtto/adapters/deno'
import { rateLimit } from '@tzezar/throtto/adapters/http'Every adapter exports
rateLimit- the import path tells you which framework. For composition withpipe()and wrappers, usecreateLimiterfrom core to avoid name collision.
// Express - inline (simplest)
app.use(rateLimit('100/minute'))
// Hono
app.use('*', rateLimit('100/minute'))
// Next.js (middleware.ts)
const check = rateLimit({ limit: 100, window: '1m' })
// SvelteKit (hooks.server.ts)
export const handle = rateLimit({ limit: 100, window: '1m' })
// Lambda (wraps handler)
export const handler = rateLimit('100/minute', myHandler)import type { Limiter } from '@tzezar/throtto'
import { toHeaders, toErrorBody, shouldSkip } from '@tzezar/throtto/http'
function myAdapter(config: { limiter: Limiter; skipPaths?: string[] }) {
return async (req: Request): Promise<Response | null> => {
const path = new URL(req.url).pathname
if (shouldSkip(path, req.method, { skipPaths: config.skipPaths })) return null
const result = await config.limiter.check(req.headers.get('x-api-key') ?? 'anon')
if (result.allowed) return null // pass through
return new Response(JSON.stringify(toErrorBody(result)), {
status: 429,
headers: toHeaders(result),
})
}
}Build complex limiters by composing simple wrappers:
import { rateLimit, pipe, withAllowlist, withDryRun, withOverride, withThresholds } from '@tzezar/throtto'
const limiter = pipe(
rateLimit('1000/hour'),
withAllowlist({ allowlist: ['monitoring-service'] }),
withThresholds({
thresholds: [
{ percent: 80, callback: (key) => console.warn(`${key} at 80%`) },
{ percent: 95, callback: (key) => console.error(`${key} near limit`) },
],
}),
withOverride(),
withDryRun(),
)| Wrapper | Description |
|---|---|
withAllowlist |
Always allow specific keys |
withDryRun |
Shadow mode - log but don't enforce |
withOverride |
Force allow/deny keys at runtime |
withThresholds |
Trigger callbacks at usage % levels |
withSoftHardLimit |
Warn before hard cutoff |
withConditional |
Reserve capacity, then confirm or cancel |
withBatch |
Check multiple keys in one call |
withGracefulShutdown |
Clean shutdown with timeout |
withAnalytics |
Collect metrics on every check (import from '@tzezar/throtto/analytics') |
import {
createCompoundLimiter,
createTieredLimiter,
createDynamicLimiter,
createHierarchyLimiter,
createScheduledLimiter,
createLazyLimiter,
} from '@tzezar/throtto'
// Compound - multiple simultaneous limits (each layer can use a different algorithm)
const compound = createCompoundLimiter([
{ name: 'burst', limiter: rateLimit({ limit: 10, window: '1s', algorithm: 'token-bucket' }) },
{ name: 'minute', limiter: rateLimit({ limit: 100, window: '1m', algorithm: 'sliding-window-counter' }) },
{ name: 'hour', limiter: rateLimit({ limit: 1000, window: '1h', algorithm: 'fixed-window' }) },
])
// Tiered - free/pro/enterprise (each tier gets its own algorithm)
import { slidingWindowCounter } from '@tzezar/throtto'
const tiered = createTieredLimiter({
tiers: [
{ name: 'free', algorithm: slidingWindowCounter({ limit: 100, window: 3_600_000 }) },
{ name: 'pro', algorithm: slidingWindowCounter({ limit: 1000, window: 3_600_000 }) },
{ name: 'enterprise', algorithm: slidingWindowCounter({ limit: 10000, window: 3_600_000 }) },
],
resolveTier: (key) => getUserPlan(key),
})
// Dynamic - per-key algorithm resolved at runtime (LRU-cached)
const dynamic = createDynamicLimiter({
algorithm: (key) => slidingWindowCounter({ limit: getLimit(key), window: 60_000 }),
maxCacheSize: 1000,
})
// Hierarchy - org → team → user cascading
const hierarchy = createHierarchyLimiter({
levels: [
{ name: 'org', algorithm: slidingWindowCounter({ limit: 10000, window: 3_600_000 }) },
{ name: 'team', algorithm: slidingWindowCounter({ limit: 1000, window: 3_600_000 }) },
{ name: 'user', algorithm: slidingWindowCounter({ limit: 100, window: 3_600_000 }) },
],
resolveKeys: (key) => ({ org: getOrg(key), team: getTeam(key), user: key }),
})
// Scheduled - time-based rules (first match wins)
const scheduled = createScheduledLimiter({
schedule: [
{ name: 'business-hours', when: { hours: [9, 17] }, algorithm: slidingWindowCounter({ limit: 50, window: 60_000 }) },
{ name: 'default', when: 'default', algorithm: slidingWindowCounter({ limit: 100, window: 60_000 }) },
],
})
// Lazy - deferred initialization (factory as first arg)
const lazy = createLazyLimiter(
async () => rateLimit('100/minute'),
{ pendingBehavior: 'allow' },
)import { throttle, debounce, createPenaltyBox, createQuota, withCostMapping, withBackpressure } from '@tzezar/throtto'
// Throttle - one call per interval
const throttled = throttle(myFunction, { interval: '1s' })
// Debounce - collapse rapid calls
const debounced = debounce(myFunction, { delay: '500ms' })
// Penalty box - escalating lockout for repeat offenders
const penalties = createPenaltyBox({
levels: [
{ duration: '1m' },
{ duration: '5m' },
{ duration: '1h' },
],
maxEntries: 10000,
})
// Quota - budget-based limits
const quota = createQuota({ limit: 1000, window: '1d', maxKeys: 50000 })
// Cost mapping - different endpoints cost different amounts
const limiter = pipe(
rateLimit('100/minute'),
withCostMapping({ '/search': 5, '/export': 20, default: 1 }),
)
// Backpressure - slow callers down instead of rejecting
const limiter = pipe(
rateLimit('100/minute'),
withBackpressure({ strategy: 'delay', maxDelay: '5s' }),
)import { toHeaders, toErrorBody } from '@tzezar/throtto/http'
const result = await limiter.check('user-123')
// draft-7 (RFC 9309) - default
const headers = toHeaders(result)
// { 'RateLimit': 'limit=100, remaining=95, reset=58' }
// draft-6
const draft6 = toHeaders(result, { format: 'draft-6' })
// Legacy (X-RateLimit-*)
const legacy = toHeaders(result, { format: 'legacy' })
// Error body - simple
const body = toErrorBody(result)
// { error: 'Too Many Requests', message: 'Rate limit exceeded. Try again in 58 seconds.', retryAfter: 58 }
// Error body - RFC 7807
const rfc7807 = toErrorBody(result, { format: 'rfc7807' })
// { type: 'https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429', title: 'Too Many Requests', status: 429, ... }import { byIp, byUser, byApiKey, byComposite, byCustom, byPath } from '@tzezar/throtto/http'
// By IP with proxy trust depth (prevents spoofing)
const key = byIp({ trustDepth: 1 })
// By user header
const key = byUser()
// By API key header
const key = byApiKey()
// By request path
const key = byPath()
// Composite - combine multiple resolvers
const key = byComposite(byUser(), byIp())
// Custom
const key = byCustom((req) => req.headers['x-tenant-id'] ?? 'default')import { withAnalytics } from '@tzezar/throtto/analytics'
import { toPrometheus, toJSON, toCSV } from '@tzezar/throtto/analytics'
const limiter = withAnalytics(rateLimit('100/minute'), { enableStream: true })
// Use normally
await limiter.check('user-1')
// Get aggregated metrics
const metrics = limiter.getMetrics()
console.log(metrics.totalRequests, metrics.denyRate, metrics.avgLatencyMs)
// Export as Prometheus
const prometheus = toPrometheus(limiter.getMetrics())
// Export as JSON
const json = toJSON(limiter.getMetrics())
// Export as CSV
const csv = toCSV(limiter.getMetrics())
// Real-time event stream
const stream = limiter.getStream()
if (stream) {
for await (const event of stream.subscribe()) {
console.log(event.key, event.allowed, event.latencyMs)
}
}For NestJS and other decorator-based frameworks:
import { Throttle, SkipThrottle, ThrottleCost, withThrottle } from '@tzezar/throtto/decorators'
@Throttle({ limit: '100/minute' })
class ApiController {
@Throttle({ limit: '10/minute', cost: 5 })
async expensiveOperation() { /* ... */ }
@SkipThrottle()
async healthCheck() { /* ... */ }
@ThrottleCost(10)
async heavyQuery() { /* ... */ }
}
// Programmatic alternative (no decorators needed)
const throttled = withThrottle(myFunction, {
limiter,
key: 'my-operation',
cost: 2,
})import { createTestLimiter } from '@tzezar/throtto/testing'
import { assertAllowed, assertDenied, exhaust } from '@tzezar/throtto/testing'
// One-liner test setup - limiter + controllable clock + store
const { limiter, clock, store } = createTestLimiter({ limit: 5, window: '1m' })
// Assert results
const result = await limiter.check('user-1')
assertAllowed(result)
// Exhaust the limit
await exhaust(limiter, 'user-1', 5)
assertDenied(await limiter.check('user-1'))
// Time travel
clock.advance(60_000) // fast-forward 1 minute
assertAllowed(await limiter.check('user-1')) // window reset
// Mock store with failure injection
import { mockStore } from '@tzezar/throtto/testing'
const store = mockStore({
failAfter: 3, // fail after 3 calls
latencyMs: 50, // simulate 50ms latency
})import { withOverride } from '@tzezar/throtto'
const limiter = withOverride(rateLimit('100/minute'))
limiter.setOverride('vip-user', { action: 'allow' }) // always allow
limiter.setOverride('abusive-ip', { action: 'deny' }) // always deny
limiter.removeOverride('vip-user') // remove overrideimport { exportState, importState } from '@tzezar/throtto'
// Backup current state
const snapshot = await exportState(store, ['key1', 'key2'])
// Restore on another instance
const result = await importState(store, snapshot)
console.log(`Imported ${result.imported} keys`)import { createHealthCheck } from '@tzezar/throtto'
const health = createHealthCheck({ store })
const status = await health.check()
// { status: 'healthy', store: { connected: true, latencyMs: 12 }, uptime: 1234 }import { withGracefulShutdown } from '@tzezar/throtto'
const limiter = withGracefulShutdown(rateLimit('100/minute'), {
drainTimeout: 5000,
onNewRequest: 'deny',
})
process.on('SIGTERM', () => limiter.shutdown())Throtto is written in strict TypeScript and exports complete type definitions:
import type {
Limiter,
Store,
Algorithm,
RateLimitResult,
AllowedResult,
DeniedResult,
RateLimitInfo,
Clock,
LimiterConfig,
LimiterHooks,
} from '@tzezar/throtto'
// Type guards
import { isAllowed, isDenied, isLimiter } from '@tzezar/throtto'
if (isAllowed(result)) {
result.remaining // typed as AllowedResult
}Prevent duplicate keys from casing or whitespace:
const limiter = rateLimit({
limit: 100,
window: '1m',
normalizeKey: 'lowercase', // 'User-123' → 'user-123'
// normalizeKey: 'trim', // ' user-123 ' → 'user-123'
// normalizeKey: 'lowercase-trim',
// normalizeKey: (key) => key.replace(/[^a-z0-9]/g, ''),
})Control behavior when the store is unavailable:
const limiter = rateLimit({
limit: 100,
window: '1m',
store: redisStore({ client }),
failMode: 'open', // allow on store errors (default: 'open')
fallbackStore: memoryStore(), // optional: fall back to memory
})Run with pnpm run bench. Results on Intel Core 7 240H, Node.js v22, memory store:
Algorithms (single key, sustained throughput)
| Algorithm | ops/sec | avg | p99 |
|---|---|---|---|
| Fixed Window | 2.19M | 381 ns | 714 ns |
| Sliding Window Counter | 2.13M | 393 ns | 634 ns |
| Sliding Window Log | 2.06M | 411 ns | 1.2 μs |
| Token Bucket | 2.16M | 388 ns | 633 ns |
| Leaky Bucket | 2.15M | 390 ns | 602 ns |
| GCRA | 2.16M | 388 ns | 581 ns |
| Concurrency | 155.1K | 6.4 μs | 11.2 μs |
Composition overhead (per check, single wrapper vs bare limiter)
| Benchmark | ops/sec | avg | overhead |
|---|---|---|---|
| Bare limiter | 2.10M | 401 ns | - |
| + withAllowlist | 1.78M | 485 ns | +21% |
| + withDryRun | 1.96M | 434 ns | +8% |
| + withOverride | 1.92M | 445 ns | +11% |
| + withThresholds | 1.67M | 522 ns | +30% |
| + withAnalytics | 1.45M | 613 ns | +53% |
| pipe(3 wrappers) | 1.53M | 575 ns | +43% |
HTTP utilities (pure computation, no I/O)
| Utility | ops/sec | avg |
|---|---|---|
| toHeaders(draft-7) | 5.56M | 105 ns |
| toHeaders(legacy) | 5.06M | 121 ns |
| toErrorBody(simple) | 5.35M | 108 ns |
| toErrorBody(rfc7807) | 5.50M | 106 ns |
| parseDuration("1m30s") | 3.66M | 199 ns |
Memory store (raw operations)
| Operation | ops/sec | avg |
|---|---|---|
| get (miss) | 4.18M | 165 ns |
| get (hit) | 3.31M | 227 ns |
| set | 3.18M | 241 ns |
| atomic | 2.86M | 273 ns |
Run benchmarks yourself
pnpm run benchResults vary by CPU, Node.js version, and system load. The benchmark suite is in benchmarks/run.ts.
throtto is tree-shakeable and side-effect free. You only pay for what you import:
| What you import | Gzipped size |
|---|---|
rateLimit (most common) |
4.1 KB |
createLimiter |
1.3 KB |
Single algorithm (e.g. tokenBucket) |
~900 B |
Single store (e.g. redisStore) |
~870 B |
Single wrapper (e.g. withAllowlist) |
~430 B |
pipe |
176 B |
toHeaders + toErrorBody |
~800 B |
| Full barrel (no tree-shaking) | 13.5 KB |
Sizes from bundlephobia exports analysis. With Vite, Rollup, or webpack 5, only what you import is included.
How this compares (full package, minified + gzipped, from bundlephobia):
| Package | Gzipped | Dependencies | What you get |
|---|---|---|---|
| @tzezar/throtto (tree-shaken) | ~4.1 KB | 0 | rateLimit + 1 algorithm + memory store |
| @upstash/ratelimit | 9.2 KB | 1 | 3 algorithms, Upstash only |
| @tzezar/throtto (full barrel) | 13.5 KB | 0 | 7 algorithms, 6 stores, 18 adapters, composition, patterns |
| bottleneck | 14.2 KB | 0 | Concurrency + rate limiting, no framework adapters |
| express-rate-limit | 15.0 KB | 2 | 1 algorithm, 1 store, Express only |
| rate-limiter-flexible | 16.9 KB | 0 | Comparable features, CJS only |
Zero runtime dependencies in core. Store and framework adapters use optional peer dependencies - install only what you use.
Install only the ones you need:
| Store | Peer Dependency |
|---|---|
| Redis | ioredis >= 5 |
| Upstash | @upstash/redis >= 1 |
| PostgreSQL | pg >= 8 |
| MySQL | mysql2 >= 3 |
| SQLite | better-sqlite3 >= 9 |
Contributions are welcome! Please open an issue first to discuss significant changes.
git clone https://github.com/tzezar/throtto.git
cd throtto
pnpm install
pnpm run test # vitest (619 unit tests)
pnpm run test:integration # all 17 integration apps (Node, Bun, Deno)
pnpm run typecheck # tsc --noEmit
pnpm run build # tsup
pnpm run lint # biomeSee CONTRIBUTING.md for detailed guidelines on adding algorithms, stores, and adapters.
Full documentation is in the docs/ directory:
- Algorithms - all 7 algorithms with trade-offs and examples
- Storage Adapters - setup guides for all 6 stores
- Framework Adapters - all 18 frameworks with copy-paste examples
- Composition -
pipe(), wrappers, advanced limiters - Patterns - throttle, debounce, penalty box, quota, cost, backpressure
- HTTP Utilities - headers, error bodies, key resolvers
- Testing - controllable clocks, mock stores, assertion helpers
- Analytics - metrics, Prometheus export, event streaming
Step-by-step guides in the examples/ directory:
| Example | What you'll learn |
|---|---|
| Basic rate limiting | rateLimit(), check/consume/peek/reset, presets, cost, key normalization |
| Express integration | Middleware setup, inline config, custom keys, per-route limits |
| Composition | pipe(), wrappers, override, dry-run, production setup |
| Storage adapters | Memory, Redis, Upstash, PostgreSQL, cache layer, schema generation |
| Testing | createTestLimiter, controllable clock, mock store, Vitest examples |
| Advanced limiters | Compound, tiered, dynamic, hierarchy, scheduled, lazy |
| Custom adapter | Write your own framework adapter (~30 lines) |
MIT © throtto contributors