Skip to content

Getting Started

Ameya Borkar edited this page May 27, 2026 · 2 revisions

Getting Started

ThrottleKit is a pluggable rate-limiting toolkit: pick an algorithm, a backend, and a framework adapter, and the limiting logic stays the same across all three.

Install

npm i throttlekit

Peer dependencies are optional — install only the ones for the adapters you use (ioredis for throttlekit/redis, pg for throttlekit/postgres, express, @opentelemetry/api, …). The Web fetch adapter (throttlekit/fetch) needs none.

Your first limiter

In-memory GCRA — no infrastructure required:

import { rateLimit, gcra } from "throttlekit";

const limiter = rateLimit({
  // 100 requests per minute, with an instantaneous burst allowance of 20.
  strategy: gcra({ limit: 100, periodMs: 60_000, burst: 20 }),
  // `store` defaults to a fresh in-process MemoryStore.
});

const decision = await limiter.check(userId); // cost defaults to 1
if (!decision.allowed) {
  // 429 with Retry-After: Math.ceil(decision.retryAfterMs / 1000)
  throw new Error(`rate limited; retry in ${decision.retryAfterMs}ms`);
}

The Decision object

Every check returns an immutable Decision:

interface Decision {
  allowed: boolean;     // permit or reject
  limit: number;        // effective ceiling (burst capacity or window quota)
  remaining: number;    // whole units left before the next rejection (never negative)
  resetAt: number;      // epoch-ms when the limiter is fully replenished
  retryAfterMs: number; // 0 when allowed; otherwise how long to wait
}

The synchronous fast path and cost

With the in-memory store you also get a synchronous, zero-await fast path, and a cost for weighting:

const d = limiter.checkSync(userId); // MemoryStore only; throws on async stores
await limiter.check(userId, 5);      // this request costs 5 units

Checking many keys at once

Each key is evaluated at one consistent timestamp and returned in input order:

const decisions = await limiter.checkMany([ip, userId, apiKey]); // Decision[] in input order
const all = limiter.checkManySync(keys);                         // MemoryStore: one loop, no promises

On an async store the checks fire concurrently — and collapse to a single round trip on clients that pipeline same-tick commands (node-redis, or ioredis with enableAutoPipelining).

Introspecting without consuming — peek & forecast

Two non-consuming reads — missing from almost every limiter — for client retry/UX and capacity planning. Neither spends anything, on any backend:

const p = await limiter.peek(userId);  // the current Decision (remaining now, resetAt) — spends nothing
limiter.peekSync(userId);              // synchronous fast path (MemoryStore)

const f = await limiter.forecast(userId, 1);
// { spendableNow, nextReplenishAt, fullAt }:
//   spendableNow      — how many cost-1 requests are admissible right now
//   nextReplenishAt   — epoch-ms when capacity next increases (a window reset, or the next token)
//   fullAt            — epoch-ms when fully replenished to the ceiling

peek returns the same Decision shape as check, but its remaining/resetAt describe the current state rather than a post-consume projection. Both methods are implemented for every built-in strategy and work in-memory (sync + async) and over a distributed store: on Redis the read is a single round trip that reads the stored state without running the consuming Lua, so it can never spend a unit. (They're optional on the Limiter interface — present on rateLimit, absent where a non-consuming read isn't well-defined.)

Deterministic time

Time is injected everywhere — no Date.now() hides inside an algorithm — so every limit is reproducible to the millisecond. This is what makes the entire test suite deterministic.

import { rateLimit, gcra, ManualClock, MemoryStore } from "throttlekit";

const clock = new ManualClock(0);
const limiter = rateLimit({
  strategy: gcra({ limit: 2, periodMs: 1_000 }), // burst defaults to 2
  clock,
  store: new MemoryStore({ clock }),
});

(await limiter.check("k")).allowed; // true
(await limiter.check("k")).allowed; // true
(await limiter.check("k")).allowed; // false — burst exhausted
clock.advance(500);                 // one emission interval (1000/2) later
(await limiter.check("k")).allowed; // true

ManualClock exposes .advance(ms), .set(ms), .now().

Next steps

Runnable versions of every feature live in examples/.

Clone this wiki locally