@hey-amanthakur/throttle-box
v1.0.0
Published
Throttle-Box — a framework-agnostic token bucket API rate limiter for Express, Fastify, Koa and NestJS. Zero runtime dependencies, pluggable store.
Maintainers
Readme
Throttle-Box
A framework-agnostic token bucket rate limiter for Node.js
Zero runtime dependencies · Pluggable store · Express · Fastify · Koa · NestJS
Overview
Throttle-Box is a lightweight, framework-agnostic token bucket rate limiter for Node.js. It protects APIs from abuse and overload with a mathematically sound algorithm, ships with adapters for the most popular Node frameworks, and runs with zero runtime dependencies — only what Node ships with.
Highlights
| | |
| --- | --- |
| Zero dependencies | No transitive supply-chain risk; nothing to audit beyond Node itself. |
| Pluggable store | In-memory by default. Implement one method to back it with Redis, Postgres, DynamoDB, or any distributed store. |
| Framework adapters | Drop-in middleware/guard for Express, Fastify, Koa, and NestJS. |
| Per-key buckets | Isolate limits by IP, API key, user id, route, or any custom key. |
| Dynamic per-route config | Override capacity and refill rate per route, per user tier, or per request. |
| RFC 9431 compliant | Emits RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, RateLimit-Policy, and Retry-After headers. |
| Graceful degradation | Store errors never crash your request pipeline — the request passes through by default. |
| Dual ESM + CommonJS | Ships both module formats with full TypeScript type definitions. |
Table of Contents
- Installation
- How the token bucket works
- Quick start
- Core API
- Framework adapters
- Configuration reference
- Dynamic per-route configuration
- Custom rejection responses
- Writing a custom store
- Node.js support
- Testing
- Contributing
- Publishing
- License
Installation
npm install @hey-amanthakur/throttle-box
pnpm add @hey-amanthakur/throttle-box
yarn add @hey-amanthakur/throttle-boxFramework packages are optional peer dependencies — install only the one you use:
npm install express
npm install fastify
npm install koa
npm install @nestjs/common @nestjs/core reflect-metadata # NestJS onlyHow the token bucket works
A token bucket holds up to capacity tokens. Each request consumes tokens (default 1). Tokens refill continuously at refillRate tokens per second up to capacity. When a request arrives and there are not enough tokens, it is rejected with 429 Too Many Requests and a Retry-After indicating when enough tokens will have refilled.
| Parameter | Description |
| --- | --- |
| capacity | Maximum tokens in the bucket (the burst size). |
| refillRate | Tokens added per second (the sustained rate). |
| tokens | Tokens consumed per request (default 1). |
| key | Bucket identifier — typically IP, API key, or user id. |
Example:
capacity: 60, refillRate: 1, tokens: 1→ a client may burst up to 60 requests instantly, then sustain 1 request/second indefinitely.
Quick start
import { MemoryStore, RateLimiter } from '@hey-amanthakur/throttle-box';
const limiter = new RateLimiter(new MemoryStore(), {
capacity: 60,
refillRate: 1, // tokens per second
tokens: 1, // tokens per request
keyBy: (req: any) => req.headers['x-api-key'] ?? req.ip,
});
// Use anywhere — pure logic, no framework needed
const { result } = await limiter.decide({ req: someRequest });
if (!result.allowed) {
// result.retryAfterMs tells you when to retry
}Core API
RateLimiter
import { RateLimiter, MemoryStore } from '@hey-amanthakur/throttle-box';
const limiter = new RateLimiter(store, config);| Method | Description |
| --- | --- |
| decide({ req, res?, config? }) | Resolve the key from the request, consume tokens, return a RateLimitDecision. This is what framework adapters call. |
| consume(key, config?) | Consume tokens for an explicit key — useful in non-HTTP contexts (jobs, websockets, CLI). |
| reset(key?) | Reset one key or the entire store. |
| inspect(key) | Peek at a bucket's state (only if the store implements peek). |
| defaultConfig | A copy of the limiter's default config. |
RateLimitDecision:
interface RateLimitDecision {
result: ConsumeResult; // { allowed, remaining, capacity, refillRate, retryAfterMs, resetAt, requested }
key: string;
config: RateLimitConfig;
}MemoryStore
The default in-process store. Buckets idle for longer than ttlMs are swept automatically.
import { MemoryStore } from '@hey-amanthakur/throttle-box';
const store = new MemoryStore({ ttlMs: 10 * 60 * 1000 });Warning:
MemoryStoreis per-process. Behind a load balancer with N instances, each instance has its own counters — effective limits are N× the configured rate. For shared state across instances, use a distributed store (see Writing a custom store).
The Store interface
Any store — Redis, Postgres, Memcached, DynamoDB — only needs to implement:
interface Store {
consume(key: string, options: ConsumeOptions): Promise<ConsumeResult>;
peek?(key: string): Promise<BucketState | undefined>; // optional
reset?(key?: string): Promise<void>; // optional
size?(): Promise<number>; // optional
}where
interface ConsumeOptions {
capacity: number;
refillRate: number;
tokens: number;
now?: number;
}
interface ConsumeResult {
allowed: boolean;
remaining: number;
capacity: number;
refillRate: number;
retryAfterMs: number;
resetAt: number; // epoch ms
requested: number;
}The pure refill + consume algorithm is exported so you can reuse the exact same math in a Lua script or DB function:
import { refillAndConsume } from '@hey-amanthakur/throttle-box';
const { state, result } = refillAndConsume(existingState, {
capacity, refillRate, tokens, now: Date.now(),
});Key extraction
By default the limiter keys on the client IP, reading X-Forwarded-For first, then req.ip / req.info.remoteAddress / req.socket.remoteAddress. Override with keyBy:
const limiter = new RateLimiter(store, {
capacity: 100,
refillRate: 10,
keyBy: (req) => req.user.id, // function — may be async
// keyBy: 'global', // or a literal string for a single shared bucket
});Framework adapters
Express
import express from 'express';
import { MemoryStore, RateLimiter, expressRateLimit } from '@hey-amanthakur/throttle-box/express';
const limiter = new RateLimiter(new MemoryStore(), {
capacity: 60,
refillRate: 1,
});
const app = express();
app.use(expressRateLimit({ limiter }));Per-route override with custom rejection:
app.get(
'/search',
expressRateLimit({
limiter,
dynamic: (req) => ({ capacity: 5, refillRate: 1 }),
onLimited: (_req, res, { result }) => {
res.status(429).json({
error: 'slow down',
retryAfter: Math.ceil(result.retryAfterMs / 1000),
});
},
}),
handler,
);Fastify
import Fastify from 'fastify';
import { MemoryStore, RateLimiter, fastifyRateLimitPlugin } from '@hey-amanthakur/throttle-box/fastify';
const app = Fastify();
const limiter = new RateLimiter(new MemoryStore(), { capacity: 60, refillRate: 1 });
await app.register(fastifyRateLimitPlugin({ limiter }));Koa
import Koa from 'koa';
import { MemoryStore, RateLimiter, koaRateLimit } from '@hey-amanthakur/throttle-box/koa';
const app = new Koa();
const limiter = new RateLimiter(new MemoryStore(), { capacity: 30, refillRate: 0.5 });
app.use(koaRateLimit({ limiter }));NestJS
import { Module, Controller, Get } from '@nestjs/common';
import {
RateLimitModule,
UseRateLimit,
} from '@hey-amanthakur/throttle-box/nestjs';
@Module({
imports: [
RateLimitModule.forRoot({
config: { capacity: 100, refillRate: 10, tokens: 1 },
}),
],
})
class AppModule {}
@Controller('api')
class ApiController {
@Get('search')
@UseRateLimit({ capacity: 5, refillRate: 1, tokens: 1 }) // override per route
search() {
return { results: [] };
}
}Async registration (e.g. load config from ConfigService or build a Redis store):
RateLimitModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
config: {
capacity: config.get('RATE_LIMIT_CAPACITY'),
refillRate: config.get('RATE_LIMIT_REFILL'),
},
store: new RedisStore(redisClient), // your custom store
}),
}),Register the guard globally so every route is limited without per-controller @UseGuards:
import { APP_GUARD } from '@nestjs/core';
@Module({
providers: [{ provide: APP_GUARD, useExisting: 'RateLimitGuard' }],
})
class AppModule {}Configuration reference
interface RateLimitConfig {
/** Max tokens in the bucket (burst size). Default 60. */
capacity: number;
/** Tokens added per second (sustained rate). Default 1. */
refillRate: number;
/** Tokens consumed per request. Default 1. */
tokens: number;
/** Bucket key extractor — function (may be async) or literal string. Defaults to client IP. */
keyBy?: string | ((req: unknown) => string | Promise<string>);
/** Return true to bypass rate limiting for this request. */
skip?: (req: unknown) => boolean | Promise<boolean>;
/** Inject RFC 9431 RateLimit-* headers on the response. Default true. */
headers?: boolean;
/** Called if the store throws — defaults to logging and passing through. */
onError?: (err: unknown) => void;
}Dynamic per-route configuration
Every adapter accepts a dynamic(req, res) callback returning a partial RateLimitConfig that overrides the limiter defaults for that single request. Use it to apply tighter limits to expensive routes, free vs. paid tiers, etc.
expressRateLimit({
limiter,
dynamic: (req) => {
if (req.path.startsWith('/admin')) return { capacity: 5, refillRate: 0.5 };
if (req.user?.plan === 'pro') return { capacity: 1000, refillRate: 20 };
return {};
},
}),In NestJS, use @UseRateLimit({...}) on a method or controller — method-level metadata takes precedence over class-level.
Custom rejection responses
Provide an onLimited(req, res, decision) callback in any adapter to fully control the 429 response. decision.result exposes retryAfterMs, remaining, resetAt, etc. If omitted, a sensible JSON 429 body is sent automatically.
onLimited: (_req, res, { result }) => {
res
.status(429)
.set('Retry-After', String(Math.ceil(result.retryAfterMs / 1000)))
.json({
error: 'Too Many Requests',
retryAfterSeconds: Math.ceil(result.retryAfterMs / 1000),
});
},Writing a custom store
A minimal Redis-backed store using an atomic Lua script — guarantees correctness across concurrent instances:
import {
refillAndConsume,
type Store,
type ConsumeOptions,
type ConsumeResult,
} from '@hey-amanthakur/throttle-box';
const LUA = `
local state = redis.call('HMGET', KEYS[1], 'tokens', 'lastRefill')
local s = nil
if state[1] then
s = { tokens = tonumber(state[1]), lastRefill = tonumber(state[2]) }
end
-- mirror refillAndConsume here (or call it from Node via EVALSHA + GET/SET)
redis.call('HMSET', KEYS[1], 'tokens', newTokens, 'lastRefill', newLastRefill)
return { allowed, remaining, retryAfterMs, resetAt }
`;
export class RedisStore implements Store {
constructor(
private redis: {
evalsha: (
sha: string,
keys: string[],
args: (string | number)[],
) => Promise<any>;
},
) {}
async consume(key: string, opts: ConsumeOptions): Promise<ConsumeResult> {
const res = await this.redis.evalsha(SHA, [`rl:${key}`], [
opts.capacity,
opts.refillRate,
opts.tokens,
opts.now ?? Date.now(),
]);
return {
allowed: !!res[0],
remaining: res[1],
retryAfterMs: res[2],
resetAt: res[3],
capacity: opts.capacity,
refillRate: opts.refillRate,
requested: opts.tokens,
} as ConsumeResult;
}
}For non-Redis stores without atomic scripting, you can still implement consume by reading, calling the exported refillAndConsume, and writing back — accepting the small race window that implies.
Node.js support
Tested across the Node.js versions the industry currently runs and the newest line:
| Node line | Status | Supported | | --- | --- | :---: | | 20.x | EOL ~Apr 2026, still widely deployed | ✅ | | 22.x | Active LTS | ✅ | | 24.x | Active LTS (newest LTS) | ✅ | | 26.x | Current | ✅ |
engines.node: ">=20.19.0". CI runs the full matrix on every push — see .github/workflows/ci.yml.
Testing
npm test # run unit tests with tsx + node:test
npm run typecheck # tsc --noEmit
npm run build # tsup dual ESM/CJS + .d.tsThe test suite covers the token bucket math, multi-key isolation, persistence, refill behavior, dynamic per-call config overrides, skip, and IP extraction.
Contributing
Contributions are welcome and appreciated. Please read the Contributing Guidelines before opening a pull request.
- Bug reports & feature requests → open an issue
- Pull requests → target the
mainbranch; include tests for any new behavior - Discussions & questions → start a discussion
By contributing, you agree that your contributions will be licensed under the MIT License.
Publishing
npm version patch
npm publish --access public # scoped packages are private by defaultThe published artifact contains only dist/, README.md, LICENSE, and CONTRIBUTING.md (see the files field in package.json). The prepublishOnly script runs typecheck, tests, and build automatically.
License
MIT © 2026 Aman Thakur
