DEV Community

Cover image for I Built a Zero-Dependency Rate Limiter That Works Across Every Node.js Framework
Aman Thakur
Aman Thakur

Posted on

I Built a Zero-Dependency Rate Limiter That Works Across Every Node.js Framework

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:

  1. Works with Express, Fastify, Koa, AND NestJS
  2. Has zero runtime dependencies (only what Node ships with)
  3. Lets me swap the backing store (in-memory for dev, Redis for prod)
  4. 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 capacity tokens (your burst size)
  • Each request consumes a token
  • Tokens refill continuously at refillRate per second (your sustained rate)
  • If the bucket is empty → 429 Too Many Requests with a Retry-After header
capacity: 60, refillRate: 1
→ Burst 60 requests instantly, then sustain 1/sec forever
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 {};
}
Enter fullscreen mode Exit fullscreen mode

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() { ... }
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 in Promise.resolve().then())
  • Koa's ctx.body setter not being reached because defaultReject found a send() 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)