-
Notifications
You must be signed in to change notification settings - Fork 0
Migrating
Drop-in paths from the three most common Node rate limiters. The recurring theme: ThrottleKit
returns an immutable Decision ({ allowed, limit, remaining, resetAt, retryAfterMs }) instead
of throwing, defaults to GCRA (smooth pacing, no 2× boundary burst) rather than a fixed-window
counter, and runs the same strategy on every backend — so you're never locked to one vendor's
client.
Units: ThrottleKit takes milliseconds everywhere (
periodMs,windowMs).express-rate-limitalso uses ms;rate-limiter-flexibleuses seconds (duration);@upstash/ratelimituses duration strings ("10 s"). Convert at the boundary.
// before
import rateLimit from "express-rate-limit";
app.use(rateLimit({ windowMs: 60_000, limit: 100, standardHeaders: "draft-7" }));
// after — GCRA by default (smooth pacing, no 2× boundary burst), same IETF headers
import { expressRateLimit } from "throttlekit/express";
import { gcra } from "throttlekit";
app.use(expressRateLimit({ strategy: gcra({ limit: 100, periodMs: 60_000 }) }));
// want the classic window? swap in fixedWindow({ limit: 100, windowMs: 60_000 })express-rate-limit |
ThrottleKit | Notes |
|---|---|---|
windowMs |
periodMs (GCRA) / windowMs (fixedWindow) |
same milliseconds |
limit / max
|
strategy({ limit }) |
|
standardHeaders: "draft-7" |
emit: { draft: true } |
the default |
legacyHeaders: true |
emit: { legacy: true } |
X-RateLimit-* |
keyGenerator: (req) => … |
key: (req) => … |
default is a proxy-correct, IPv6-/64-aggregated client IP |
store: new RedisStore(...) |
store: new RedisStore({ client }) from throttlekit/redis
|
|
handler / message
|
handler |
own the 429 body |
skip / skipSuccessfulRequests
|
gate conditionally — only call the limiter when you mean to | no built-in skip flag |
validate (proxy checks) |
trustProxy / ipv6Prefix on the adapter |
trust is opt-in (hop count or CIDR) |
Behavioural change: express-rate-limit defaults to a fixed window (admits up to 2× across a
boundary). ThrottleKit defaults to GCRA; choose fixedWindow explicitly if you want the old
semantics. Proxy trust is off by default here (the socket peer), so X-Forwarded-For can't be
spoofed unless you opt in.
// before — throws RateLimiterRes on exhaustion
try {
await rl.consume(key, 1);
} catch (res) {
return reply.code(429).header("Retry-After", Math.ceil(res.msBeforeNext / 1000)).send();
}
// after — one atomic Lua round trip; a Decision object, no throw
import { rateLimit, gcra } from "throttlekit";
import { RedisStore } from "throttlekit/redis";
const limiter = rateLimit({ strategy: gcra({ limit: 100, periodMs: 60_000 }), store: new RedisStore({ client: redis }) });
const d = await limiter.check(key);
if (!d.allowed) return reply.code(429).header("Retry-After", Math.ceil(d.retryAfterMs / 1000)).send();rate-limiter-flexible |
ThrottleKit | Notes |
|---|---|---|
points |
limit |
|
duration (seconds) |
periodMs / windowMs (× 1000) |
seconds → ms |
RateLimiterMemory |
MemoryStore (the default store) |
|
RateLimiterRedis({ storeClient }) |
RedisStore({ client }) |
|
RateLimiterPostgres({ storeClient }) |
PostgresStore({ pool }) |
pass a pg.Pool directly |
keyPrefix |
prefix on rateLimit(...)
|
|
.consume(key, points) |
.check(key, cost) |
returns a Decision; never throws on deny |
catch (res) → res.msBeforeNext |
d.retryAfterMs |
|
res.remainingPoints |
d.remaining |
|
.get(key) (read without consuming) |
.peek(key) |
non-consuming on every backend |
insuranceLimiter (fallback on store error) |
fail: "open", or twoTier({ mode: "leased" })
|
local credits ride out an L2 blip |
.reward / .penalty
|
vary cost per call |
|
.block(key, sec) |
maintain a separate denylist | not a limiter concern here |
Biggest shape change: throw-on-deny → a Decision. Replace try/catch with if (!d.allowed),
and read d.retryAfterMs / d.remaining / d.resetAt. The algorithm also differs — consume is a
fixed-window counter; ThrottleKit's default is GCRA (use fixedWindow to match exactly).
// before — Upstash-REST-specific
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const rl = new Ratelimit({ redis: Redis.fromEnv(), limiter: Ratelimit.slidingWindow(10, "10 s") });
const { success, remaining, reset } = await rl.limit(userId);
// after — the same sliding window, but over ANY backend (here: the Upstash REST client)
import { rateLimit, slidingWindow } from "throttlekit";
import { RedisStore, fromUpstash } from "throttlekit/redis";
import { Redis } from "@upstash/redis";
const limiter = rateLimit({
strategy: slidingWindow({ limit: 10, windowMs: 10_000 }),
store: new RedisStore({ client: fromUpstash(Redis.fromEnv()) }),
});
const d = await limiter.check(userId);
// d.allowed ↔ success, d.remaining ↔ remaining, d.resetAt ↔ reset@upstash/ratelimit |
ThrottleKit | Notes |
|---|---|---|
Ratelimit.slidingWindow(n, "10 s") |
slidingWindow({ limit: n, windowMs: 10_000 }) |
|
Ratelimit.fixedWindow(n, "60 s") |
fixedWindow({ limit: n, windowMs: 60_000 }) |
|
Ratelimit.tokenBucket(refill, "10 s", max) |
tokenBucket({ capacity: max, refillPerSec: refill / 10 }) |
per-interval → per-second |
redis: Redis.fromEnv() (Upstash REST) |
store: new RedisStore({ client: fromUpstash(Redis.fromEnv()) }) |
or any ioredis/node-redis client |
await rl.limit(id) → { success, limit, remaining, reset }
|
await limiter.check(id) → { allowed, limit, remaining, resetAt, retryAfterMs }
|
success ↔ allowed, reset ↔ resetAt (epoch-ms) |
ephemeralCache (in-process cache) |
twoTier({ l2: store, mode: "leased" }) |
a provably-bounded local tier |
pending (background analytics flush) |
— |
check is synchronous-return; use withAnalytics for insight |
| multi-region (Upstash global DB) |
twoTier leased over a shared L2 (multi-region recipe) |
same verified worldwide bound |
The win beyond a 1:1 swap: @upstash/ratelimit only speaks the Upstash REST endpoint, while
ThrottleKit runs the identical strategy on ioredis, node-redis, Upstash REST, Postgres,
DynamoDB, D1, and Deno KV — proven bit-identical by the conformance suite — and throttlekit/fetch
is dependency-free at the edge. You keep Upstash today and move the backend later without touching
limit logic.
// Tiered plans (free / pro) by API key — one store, namespaced per tier
const limiters = {
free: rateLimit({ strategy: gcra({ limit: 60, periodMs: 60_000 }), store, prefix: "free" }),
pro: rateLimit({ strategy: gcra({ limit: 1_000, periodMs: 60_000 }), store, prefix: "pro" }),
};
const d = await limiters[planFor(req)].check(apiKeyOf(req));
// Cost-weighted endpoints — charge expensive routes more from the same budget
await limiter.check(apiKeyOf(req), routeIsExpensive(req) ? 5 : 1);
// Show "x of y left" without spending — peek (new in 0.8.1)
const view = await limiter.peek(apiKeyOf(req)); // view.remaining, view.resetAt — consumes nothing-
Monthly billing quotas (e.g. "1M calls/month, resets on the 1st") —
quota({ limit, resetCadence: "calendar-month" }); see Strategies. - Per-IP and per-route in one round trip — see Advanced limiting.
- Tiered burst + sustained — compose two GCRA limiters (e.g. 10/sec and 1000/hour) and allow only if both pass.
-
Global limit across regions —
twoTierleased at a shared store; see Distributed & provable.
ThrottleKit · MIT · 1.0 — API frozen under SemVer (Stability)
- Getting Started
- Choosing a strategy
- Frameworks & the edge
- Distributed & provable
- Federation
- Scaling & the Fleet
- Unified admission
- Pillar 4 — Weighted Fair Escrow
- Middleware integration
- Distributed adaptive concurrency
- Advanced limiting
- Overload, fairness & DDoS
- Operations
- Monitoring — ThrottleKit Lens
- Policy Plans
- Replay
- Performance
- Migrating
- Polyglot & Python
- GALE & TALE