Declarative rate limiting for Node.js.
Most rate limiters give you primitives. LimitKit gives you a system — define your rules in one place, pass context, get a decision.
- Why LimitKit?
- Installation
- Quick Example
- How it works
- Core Concepts
- Policies
- Real-World Example
- Packages
- Common Recipes
- AI / LLM rate limiting
- Comparisons
- Contributing
- License
Rate limiting grows messy as your app grows. Here's what that looks like:
app.use(async (req, res, next) => {
try {
await globalLimiter.consume('global');
await ipLimiter.consume('ip:' + req.ip);
if (req.user) {
if (req.user.plan === 'pro') {
await proLimiter.consume('acc:' + req.user.id);
} else {
await freeLimiter.consume('acc:' + req.user.id);
}
if (req.path.includes('export')) {
if (req.user.plan === 'pro') {
await exportLimiter.consume('acc:' + req.user.id, 1);
} else {
await exportLimiter.consume('acc:' + req.user.id, 10);
}
}
}
} catch {
return res.status(429).json({ message: 'Too many requests' });
}
next();
});Every new rule means another limiter instance, another conditional, another place to keep in sync.
LimitKit replaces this with a schema of rules and a single consume call:
const limiter = new RateLimiter({
store,
rules: [
{
name: 'global',
key: 'global',
policy: fixedWindow({ window: 1, limit: 1000 }),
},
{
name: 'ip',
key: (req) => 'ip:' + req.ip,
policy: fixedWindow({ window: 1, limit: 500 }),
},
{
name: 'user-plan',
key: (req) => 'acc:' + req.user.id,
policy: (req) =>
req.user.plan === 'pro'
? slidingWindow({ window: 60, limit: 1000 })
: slidingWindow({ window: 60, limit: 100 }),
},
{
name: 'costly',
key: (req) => 'acc:' + req.user.id,
cost: (req) =>
req.path.includes('export') ? (req.user.plan === 'pro' ? 1 : 10) : 1,
policy: tokenBucket({ capacity: 100, refillRate: 5 }),
},
],
});app.use(async (req, res, next) => {
const result = await limiter.consume(req);
if (!result.allowed)
return res.status(429).json({ message: 'Too many requests' });
next();
});All rules in one place. No nested conditionals. Adding a rule doesn't touch existing ones.
npm install @limitkit/core @limitkit/memorySee Packages for all available packages.
import { RateLimiter } from '@limitkit/core';
import { slidingWindow, InMemoryStore } from '@limitkit/memory';
const limiter = new RateLimiter({
store: new InMemoryStore(),
rules: [
{
name: 'global',
key: 'global',
policy: slidingWindow({ window: 10, limit: 1000 }),
},
{
name: 'per-ip',
key: (ctx) => 'ip:' + ctx.ip,
cost: (ctx) => (ctx.isPriority ? 5 : 1),
policy: slidingWindow({ window: 60, limit: 60 }),
},
],
});
const result = await limiter.consume({ ip: '127.0.0.1', isPriority: false });
if (!result.allowed) {
console.log(
`Blocked by "${result.failedRule}". Retry after ${result.rules[0].availableAt}`,
);
}Prefix keys with a namespace (
ip:,acc:) to avoid collisions between rules targeting the same identifier.
Request → Rules → Key → Policy → Store → Decision
Rules are evaluated in order. Each rule resolves a key, a policy, and an optional cost — all of which can be static values, synchronous functions, or async functions. The first rule that fails short-circuits the chain. The result tells you which rule failed and the state of each evaluated rule.
A rule has four fields:
{ name, key, policy, cost? }| Field | Type | Description |
|---|---|---|
name |
string |
Unique identifier. Appears in result.failedRule when this rule is exceeded. |
key |
string | (ctx) => string |
Who to limit — IP, user ID, a global constant, anything. Can be async. |
policy |
Algorithm | (ctx) => Algorithm |
Which algorithm to apply. Can be dynamic (e.g., different limits per plan). |
cost |
number | (ctx) => number |
Weight per request (default: 1). Use for operations that should consume more than one unit. |
Algorithms are imported from the store package (@limitkit/memory or @limitkit/redis), not from @limitkit/core. The algorithm and the store must come from the same package.
| Algorithm | Signature | Best for |
|---|---|---|
| Fixed Window | fixedWindow({ window, limit }) |
Simplest option. Fast, O(1) state. Allows boundary bursts. |
| Sliding Window | slidingWindow({ window, limit }) |
Accurate per-request tracking. No boundary bursts. |
| Sliding Window Counter | slidingWindowCounter({ window, limit }) |
Approximation of sliding window with lower memory overhead. |
| Token Bucket | tokenBucket({ capacity, refillRate }) |
Smooth limiting that tolerates short bursts. |
| Leaky Bucket | leakyBucket({ capacity, leakRate }) |
Drops requests above the leak rate. Inverse of token bucket. |
| Leaky Bucket (shaping) | shapingLeakyBucket({ capacity, leakRate }) |
Delays instead of dropping. Returns availableAt for scheduling. |
| GCRA | gcra({ burst, interval }) |
Precise, low-memory rate limiting derived from telecom standards. |
shapingLeakyBucket never rejects — it tells you when a request can safely run. Use it for job queues to absorb backpressure without dropping work:
const result = await limiter.consume(ctx);
setTimeout(() => handleJob(), result.rules[0].availableAt - Date.now());Public and authenticated routes have different contexts — req.user is undefined on public routes. Rather than handle both in one limiter with conditionals, split into two rule sets and compose:
const globalRules = [
{
name: 'global',
key: 'global',
policy: fixedWindow({ window: 1, limit: 1000 }),
},
{
name: 'ip',
key: (req) => 'ip:' + req.ip,
policy: fixedWindow({ window: 5, limit: 500 }),
},
];
const authenticatedRules = [
{
name: 'user',
key: (req) => 'acc:' + req.user.id,
policy: slidingWindow({ window: 60, limit: 100 }),
},
{
name: 'costly',
key: (req) => 'acc:' + req.user.id,
cost: (req) => (req.path.includes('export') ? 10 : 1),
policy: tokenBucket({ refillRate: 5, capacity: 100 }),
},
{
name: 'plan',
key: (req) => 'acc:' + req.user.id,
policy: (req) =>
req.user.plan === 'pro'
? gcra({ burst: 1000, interval: 30 })
: gcra({ burst: 100, interval: 60 }),
},
];
const publicLimiter = new RateLimiter({ store, rules: globalRules });
const authedLimiter = new RateLimiter({
store,
rules: [...globalRules, ...authenticatedRules],
});globalRules is reused without duplication. Each limiter is a transparent description of exactly what applies.
| Package | Role | Status |
|---|---|---|
@limitkit/core |
Orchestration engine | Required |
@limitkit/redis |
Redis-backed atomic policies | Production |
@limitkit/postgres |
Postgres-backed durable policies | Production |
@limitkit/memory |
In-memory policies | Development / testing |
@limitkit/express |
Express middleware | Optional |
@limitkit/nest |
NestJS guard and decorators | Optional |
@limitkit/ai |
LLM token budgets and usage extraction | Optional |
Rate limit by IP to block brute-force attempts:
{ name: "login", key: (req) => "ip:" + req.ip, policy: slidingWindow({ window: 60, limit: 5 }) }Charge more tokens for compute-heavy routes:
{
name: "costly",
key: (req) => "acc:" + req.user.id,
cost: (req) => req.path === "/generate" ? 10 : 1,
policy: tokenBucket({ refillRate: 5, capacity: 1000 }),
}Apply different policies per subscription tier:
{
name: "plan",
key: (ctx) => "acc:" + ctx.user.id,
policy: (ctx) => ctx.user.plan === "pro"
? gcra({ burst: 1000, interval: 30 })
: gcra({ burst: 100, interval: 60 }),
}A request is a poor unit for an LLM API: one call can cost a hundred tokens or a hundred thousand, and a frontier model's tokens can cost twenty times a small model's. @limitkit/ai budgets by what a call actually costs.
It reads token counts out of a provider response and turns a budget into the token-bucket policy that expresses it. It depends only on @limitkit/core — responses are read structurally, so no provider SDK is needed.
import { RateLimiter } from '@limitkit/core';
import { InMemoryStore, tokenBucket } from '@limitkit/memory';
import { extractOpenAIUsage, monthlyTokenBudget } from '@limitkit/ai';
const limiter = new RateLimiter({
store: new InMemoryStore(),
rules: [
{
name: 'monthly-tokens',
key: (ctx) => 'acc:' + ctx.userId,
cost: (ctx) => ctx.tokens,
policy: tokenBucket(monthlyTokenBudget({ tokens: 1_000_000 })),
},
],
});
const completion = await openai.chat.completions.create({ ... });
const usage = extractOpenAIUsage(completion);
const result = await limiter.consume({ userId, tokens: usage.totalTokens });monthlyTokenBudget, weeklyTokenBudget, and sessionTokenBudget exist because a token bucket's refillRate is tokens per second, and converting a budget by hand is easy to get wrong: 1M tokens a month is 0.386 tokens/second. Miss that by five orders of magnitude and you get a limiter that looks configured but never limits.
extractOpenAIUsage, extractAnthropicUsage, extractOllamaUsage, and extractHuggingFaceUsage all return the same shape, so one rule works across providers:
{
(inputTokens, outputTokens, totalTokens, cachedInputTokens);
}This is not cosmetic. OpenAI counts cached prompt tokens inside prompt_tokens; Anthropic's input_tokens excludes them. Charging the raw field from both silently undercounts every cached Anthropic call.
One budget can govern a whole model lineup — spend it on many cheap calls or a few expensive ones:
cost: modelWeightedCost({
weights: { 'gpt-4o': 10, 'gpt-4o-mini': 1 },
defaultWeight: 10, // fail closed on a model you haven't priced
model: (ctx) => ctx.model,
tokens: (ctx) => ctx.usage.totalTokens,
});Token counts don't exist until the model has answered, so charging afterwards can't refuse the call that overran — it can only refuse the next one. examples/llm-gateway is a working gateway built on that reality, layering a per-IP limit and a per-plan burst limit (which run before the call and can refuse it outright) with a per-user token budget (which can't). A reserve/commit API that would let the budget check close that gap is proposed in issue #27.
express-rate-limit is the right choice for simple, Express-specific rate limiting — one global or per-IP limit as middleware, minimal setup.
rate-limiter-flexible covers more storage backends and is solid for imperative use — you instantiate a limiter per strategy and call each one manually.
LimitKit is for when you outgrow the imperative model: multiple overlapping rules, dynamic policies per context, weighted request costs, and plan-based limits. The difference is whether you're writing middleware logic or declaring a rule set.
Contributions are welcome. Read CONTRIBUTING.md for development guidelines.
MIT