As developers, we've all been there — you ship an API, it goes live, and within hours someone is hammering it with 10,000 requests a minute. Maybe it's a scraper. Maybe it's a misconfigured client. Maybe it's just unexpected success. Either way, your database is on fire and you're wishing you'd added rate limiting from day one.
So I built Throttle-Box — a framework-agnostic token bucket rate limiter for Node.js with zero runtime dependencies.
The Problem
Every rate limiting library I looked at had trade-offs I didn't love:
- Some are tied to a single framework (Express-only, NestJS-only)
- Some pull in 20+ transitive dependencies
- Some don't support distributed deployments
- Some bury the core logic so deep you can't customize it
I wanted something that:
- Works with Express, Fastify, Koa, AND NestJS
- Has zero runtime dependencies (only what Node ships with)
- Lets me swap the backing store (in-memory for dev, Redis for prod)
- Is simple enough to read the entire source in one sitting
What I Built
Throttle-Box uses the token bucket algorithm — one of the most intuitive rate limiting approaches:
- A bucket holds up to
capacitytokens (your burst size) - Each request consumes a token
- Tokens refill continuously at
refillRateper second (your sustained rate) - If the bucket is empty →
429 Too Many Requestswith aRetry-Afterheader
capacity: 60, refillRate: 1
→ Burst 60 requests instantly, then sustain 1/sec forever
What Makes It Different
Zero Runtime Dependencies
Nothing. Zero. The entire package runs on what Node.js ships with. No transitive supply-chain risk, nothing to audit. If you npm install it, you get exactly one package.
Pluggable Store
In-memory by default (perfect for single-process apps and testing). But the Store interface is a single method — implement consume(key, options) and you can back it with Redis, Postgres, DynamoDB, or anything else. I even export the pure refillAndConsume function so you can mirror the exact same math in a Redis Lua script for atomic distributed limiting.
Per-Key, Per-Route, Per-Tier
Key your buckets by IP, API key, user ID, or anything else:
keyBy: (req) => req.headers['x-api-key'] ?? req.ip
Override capacity and refill rate per route at request time — tighter limits on expensive endpoints, higher limits for paid tiers:
dynamic: (req) => {
if (req.path.startsWith('/admin')) return { capacity: 5, refillRate: 0.5 };
if (req.user?.plan === 'pro') return { capacity: 1000, refillRate: 20 };
return {};
}
One Package, Four Frameworks
// Express
app.use(expressRateLimit({ limiter }));
// Fastify
app.register(fastifyRateLimitPlugin({ limiter }));
// Koa
app.use(koaRateLimit({ limiter }));
// NestJS
@UseRateLimit({ capacity: 5, refillRate: 1 })
search() { ... }
Framework packages are optional peer dependencies — install only the one you use. The core never imports Express, Fastify, Koa, or NestJS.
RFC 9431 Compliant Headers
Every response gets standard RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset, and RateLimit-Policy headers. Rejected requests get Retry-After. Your clients can implement adaptive retry without guessing.
Graceful Degradation
If your Redis store goes down, Throttle-Box doesn't take your API with it. Store errors are caught, logged via an optional onError callback, and the request passes through. Rate limiting is protection, not a single point of failure.
The Numbers
- 141 tests across 11 test files
- 100% function coverage, 97.85% line coverage
- 31.5 kB published tarball (that's smaller than most README files)
- Tested on Node.js 20, 22, 24, and 26
- Dual ESM + CommonJS build with full TypeScript types
How I Approached It
The architecture is deliberately layered:
src/bucket.ts → Pure algorithm (no I/O, no framework, no Node-specific APIs)
src/store.ts → Store interface + MemoryStore
src/limiter.ts → Orchestrator that ties store + key extraction + skip logic
src/adapters/ → Framework glue (Express, Fastify, Koa)
src/nestjs/ → NestJS guard, decorator, dynamic module
The core files (bucket.ts, store.ts, limiter.ts) have zero framework imports. This means you could use the rate limiting logic in a CLI tool, a WebSocket server, a background job — anywhere Node runs.
The shared adapter logic lives in one runMiddleware function that all three HTTP frameworks call. Adding a new framework adapter is ~30 lines of glue.
I also fixed real bugs during testing — the kind that only surface when you write tests that simulate real rejection paths:
- Synchronous
dynamic()callbacks throwing outside the Promise chain (now wrapped inPromise.resolve().then()) - Koa's
ctx.bodysetter not being reached becausedefaultRejectfound asend()method first
What's Next
The package is live on GitHub with full documentation, contributing guidelines, and a CI pipeline running on 4 Node.js versions:
🔗 github.com/hey-amanthakur/throttle-box
Coming to npm as @hey-amanthakur/throttle-box.
If you're building APIs in Node.js and want rate limiting that doesn't lock you into a framework or pull in a dependency tree — give it a look. Feedback, issues, and contributions are all welcome.
Top comments (0)