npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

Readme

Throttle-Box

A framework-agnostic token bucket rate limiter for Node.js

npm version npm downloads license build status types dependencies node

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

npm install @hey-amanthakur/throttle-box
pnpm add    @hey-amanthakur/throttle-box
yarn add    @hey-amanthakur/throttle-box

Framework 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 only

How 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: MemoryStore is 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.ts

The 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 requestsopen an issue
  • Pull requests → target the main branch; include tests for any new behavior
  • Discussions & questionsstart 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 default

The 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