Dev.to WebDev 🛠 Dev 👁 0 📖 3 min read

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 unexpe

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

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 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.

📰 Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.