Skip to content

Migrating

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

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-limit also uses ms; rate-limiter-flexible uses seconds (duration); @upstash/ratelimit uses duration strings ("10 s"). Convert at the boundary.

From express-rate-limit

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

From rate-limiter-flexible

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

From @upstash/ratelimit

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

Recipes

// 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 regionstwoTier leased at a shared store; see Distributed & provable.

Clone this wiki locally