Rate Limiting
Upstash-style rate limiting with Convex-first storage and middleware-friendly DX.
Why this package
kitcn/ratelimit is designed as a hard cutover from component-driven APIs.
| What you get | Why it matters |
|---|---|
| Upstash-style API | Easier migration if your team already knows Upstash (limit, check, getRemaining, resetUsedTokens) |
| Convex-first tables | No component registration and no component migration path to manage |
| Read dedupe helpers | Common repeated reads may reuse cached results and reduce duplicate DB fetches |
| React hook support | hookAPI() + useRatelimit() gives accurate client countdown and button states |
| Fail-closed default | Safer behavior under pressure (failureMode: "closed") |
Install
npm install kitcnpnpm add kitcnyarn add kitcnbun add kitcnScaffold the starter (required)
Rate limiting is opt-in, so scaffold the full starter once:
npx kitcn add ratelimitThat creates:
convex/lib/plugins/ratelimit/schema.tsconvex/lib/plugins/ratelimit/plugin.tsconvex/functions/plugins/ratelimit.ts
and registers ratelimitExtension() in convex/functions/schema.ts.
import { defineSchema } from 'kitcn/orm';
import { ratelimitExtension } from '../lib/plugins/ratelimit/schema';
export const tables = {
// your tables...
};
export default defineSchema(tables).extend(ratelimitExtension());Create a local ratelimit plugin
Scaffold a local plugin with a default bucket for every mutation and an
interactive example for stricter procedures. Rename or remove the example
bucket to match your application.
import {
type LimitRequest,
MINUTE,
Ratelimit,
RatelimitPlugin,
SECOND,
} from 'kitcn/ratelimit';
import type { MutationCtx } from '../../../functions/generated/server';
const fixed = (rate: number) => Ratelimit.fixedWindow(rate, MINUTE);
export const ratelimitBuckets = {
default: {
public: fixed(30),
free: fixed(60),
premium: fixed(200),
},
interactive: {
public: Ratelimit.fixedWindow(3, 30 * SECOND),
free: Ratelimit.fixedWindow(3, 30 * SECOND),
premium: Ratelimit.fixedWindow(3, 30 * SECOND),
},
} as const;
type RatelimitTier = keyof (typeof ratelimitBuckets)['default'];
export type RatelimitBucket = keyof typeof ratelimitBuckets;
type RatelimitUser = {
id: string;
isAdmin?: boolean;
plan?: 'premium' | 'team' | null;
};
type RatelimitCtx = MutationCtx & {
user?: RatelimitUser | null;
};
type RatelimitMeta = {
ratelimit?: RatelimitBucket;
};
export function getUserTier(user: RatelimitUser | null): RatelimitTier {
if (!user) return 'public';
if (user.isAdmin || user.plan) return 'premium';
return 'free';
}
async function getRequestSignals(ctx: RatelimitCtx) {
const { ip, userAgent } = await ctx.meta.getRequestMetadata();
return {
...(ip ? { ip } : {}),
...(userAgent ? { userAgent } : {}),
};
}
function getRequestIdentifier(
user: RatelimitUser | null,
signals: LimitRequest | undefined
) {
if (user) return user.id;
return signals?.ip ? `ip:${signals.ip}` : 'ip:unknown';
}
export const ratelimit = RatelimitPlugin.configure({
buckets: ratelimitBuckets,
getBucket: ({ meta }: { meta: RatelimitMeta }) => meta.ratelimit ?? 'default',
getUser: ({ ctx }: { ctx: RatelimitCtx }) => ctx.user ?? null,
getTier: getUserTier,
getSignals: ({ ctx }: { ctx: RatelimitCtx }) => getRequestSignals(ctx),
getIdentifier: ({
user,
signals,
}: {
user: RatelimitUser | null;
signals: LimitRequest | undefined;
}) => getRequestIdentifier(user, signals),
prefix: ({ bucket, tier }) => `ratelimit:${bucket}:${tier}`,
failureMode: 'closed',
enableProtection: true,
denyListThreshold: 30,
});Convex exposes request metadata in mutation and action functions: request ID, client IP, and client user-agent. For RatelimitPlugin, read it from mutation middleware. Use it for anonymous flows too — session-based helpers return {} when no session exists, which is exactly when IP-aware protection matters most.
Pick an identifier that partitions
The identifier is the rate-limit partition key. Everything that resolves to the same string shares one budget and one ratelimitState document, so a constant like 'anonymous' makes fixedWindow(30, MINUTE) a deployment-wide 30 requests per minute for all unauthenticated traffic — one crawler denies every visitor.
getSignals runs once per request, before getIdentifier, and its result is passed straight through, so keying on the request IP costs no extra getRequestMetadata() syscall.
Two consequences to plan for:
- Shared IPs share a budget. NAT, corporate proxies, and mobile CGNAT put many people behind one address. Raise the public-tier budget accordingly, or add a captcha-gated tier.
- Calls without request metadata collapse to one key. They land on
ip:unknown. Keep them offpublicMutation, or give them their own bucket.
Retention: one ratelimitState row exists per identifier per bucket:tier. Per-IP keys make that row count grow with distinct visitors. The starter includes an indexed, batched private mutation for manual cleanup.
Clean up stored state on demand
Choose olderThanMs longer than every configured window, refill horizon, or
reservation period. Run the private mutation manually and repeat only while it
returns hasMore: true:
bunx convex run plugins/ratelimit:cleanup '{"olderThanMs":86400000}'Add --prod when targeting production. Each call deletes at most 500 rows by
default; pass limit from 1 through 1000 to change the batch size.
Plugin options
| Option | Type | Description |
|---|---|---|
buckets | Record<bucket, Record<tier, ResolvedAlgorithm>> | Required. Named buckets, each mapping a tier to an algorithm. |
getBucket | ({ ctx, meta }) => bucket | Required. Picks the bucket, usually from meta.ratelimit. |
getUser | ({ ctx, meta }) => user | Required. Resolves the caller. |
getTier | (user) => tier | Required. Picks the tier within the bucket. |
getSignals | ({ ctx, meta, user, bucket, tier }) => LimitRequest | undefined | Required. Resolved once per request, before getIdentifier. |
getIdentifier | ({ ctx, meta, user, bucket, tier, signals }) => string | Required. The partition key. |
prefix | string | (args) => string | Namespaces stored state. Defaults to ratelimit:<bucket>:<tier>. |
message | string | (args) => string | Message on the thrown TOO_MANY_REQUESTS error. |
Everything below is forwarded to the limiter and behaves exactly as in Constructor options: failureMode, timeout, enableProtection, denyListThreshold, denyList, dynamicLimits, ephemeralCache.
Note: ctx.meta.getRequestMetadata() requires Convex 1.38.0 or newer.
Wire it into middleware
Apply the plugin once in your mutation builders. The default bucket covers normal writes. meta.ratelimit is an optional named-bucket override.
import { ratelimit, type RatelimitBucket } from './plugins/ratelimit/plugin';
const c = initCRPC
.meta<{ ratelimit?: RatelimitBucket }>()
.create();
export const publicMutation = c.mutation.use(ratelimit.middleware());Normal writes use the default bucket:
export const createTodo = authMutation
.input(z.object({ title: z.string().min(1) }))
.mutation(async ({ ctx, input }) => {
// business logic
});Add a named bucket only for exceptions:
export const stressTest = publicMutation
.meta({ ratelimit: 'interactive' })
.input(z.object({ id: z.string() }))
.mutation(async ({ ctx, input }) => {
// stricter bucket
});Choose your algorithm
Start simple and pick based on workload shape.
Fixed window
Best when hard windows are acceptable. Tokens reset at the start of each window.
const limiter = new Ratelimit({
db: ctx.db,
prefix: 'post:create',
limiter: Ratelimit.fixedWindow(10, '1 m'),
});Sliding window
Best when you want smoother request shaping without hard resets. Weighs the previous window proportionally so you don't get bursts at window boundaries.
const limiter = new Ratelimit({
db: ctx.db,
prefix: 'search',
limiter: Ratelimit.slidingWindow(50, '1 m'),
});Token bucket
Best for burst-friendly throughput with long-term control. Tokens refill at a steady rate up to maxTokens. Use maxReserved to allow requests to "borrow" from future tokens when the bucket is empty.
const limiter = new Ratelimit({
db: ctx.db,
prefix: 'llm:tokens',
limiter: Ratelimit.tokenBucket(1000, '1 m', 1000, { maxReserved: 3000 }),
});Algorithm options
All three algorithm builders accept an optional options object as the last argument.
| Option | Type | Default | Description |
|---|---|---|---|
shards | number | 1 | Number of shards for write distribution. The configured budget is dealt across the shards and adds back up to the total you wrote, so the enforced limit is unchanged. Higher values reduce contention at the cost of less precise counts (see Sharding). |
maxReserved | number | undefined | Finite, non-negative maximum a request can "borrow" from future capacity. Omit it for uncapped reservation headroom. Dealt across shards as whole-token headroom. Only applies to fixedWindow and tokenBucket. Not supported by slidingWindow. |
capacity | number | limit | Maximum stored tokens. Only applies to fixedWindow. Useful when you want a higher burst capacity than the per-window refill. Dealt across shards like limit, so it must also be at least shards. |
start | number | 0 | Epoch offset (ms) for window alignment. Only applies to fixedWindow. Aligns windows to a custom origin instead of epoch zero. |
Duration formats
Every window or interval parameter accepts a Duration — either a raw millisecond number or a human-readable string.
String format: "<number> <unit>" or "<number><unit>". Both '1 m' and '1m' work.
| Unit | Meaning | Example |
|---|---|---|
ms | milliseconds | '500 ms' |
s | seconds | '30 s' |
m | minutes | '1 m' |
h | hours | '1 h' |
d | days | '1 d' |
You can also use the pre-defined constants from kitcn/ratelimit:
import { SECOND, MINUTE, HOUR, DAY, WEEK } from 'kitcn/ratelimit';
Ratelimit.fixedWindow(100, MINUTE); // 60_000 ms
Ratelimit.slidingWindow(50, 30 * SECOND); // 30_000 ms
Ratelimit.tokenBucket(10, HOUR, 100); // 3_600_000 msAdd a client-side limiter UX
Server enforcement is mandatory. Client checks are for better UX — disabled buttons, countdowns, and retry hints.
Expose the hook API
First, export the hook API from a Convex file. The hookAPI() method returns a getRatelimit query and a getServerTime mutation that the React hook consumes.
import { Ratelimit } from 'kitcn/ratelimit';
const limiter = new Ratelimit({
limiter: Ratelimit.fixedWindow(3, '30 s'),
});
export const { getRatelimit, getServerTime } = limiter.hookAPI({
identifier: async (_ctx, fromClient) => fromClient ?? 'anonymous',
sampleShards: 1,
});The identifier option can be a static string, or an async callback that receives (ctx, fromClient). Use the callback to resolve the identifier server-side (e.g. from auth) while still accepting a client-provided fallback.
sampleShards controls how many shards to read when estimating the remaining count. Set it to 1 for low-cost reads, or increase it for more accurate estimates on high-shard configs.
Use the React hook
Then wire it up in your component with useRatelimit:
import { useRatelimit } from 'kitcn/ratelimit/react';
const ratelimitRef = 'ratelimitDemo:getInteractiveRatelimit' as const;
const serverTimeRef = 'ratelimitDemo:getInteractiveServerTime' as const;
const { status, check } = useRatelimit(ratelimitRef, {
identifier: sessionId,
count: 1,
getServerTimeMutation: serverTimeRef,
});
const blocked = status?.ok === false;
const retryAt = status?.retryAt;useRatelimit accepts either:
- a Convex function path string (
'module:functionName') — this is what the/ratelimitdemo uses. - a generated
FunctionReferencefromapi.
The hook returns:
| Field | Type | Description |
|---|---|---|
status | HookStatus | undefined | undefined while loading. { ok: true } when allowed, { ok: false, retryAt: number } when blocked. Auto-updates when a finite retryAt passes. Permanently oversized checks use Infinity and schedule no timer. |
check | (ts?, count?) => HookCheckValue | undefined | Manual projection function. Call it with a timestamp and count to get a precise snapshot for custom gauges or progress bars. |
The HookCheckValue returned by check() has this shape:
| Field | Type | Description |
|---|---|---|
value | number | Projected remaining tokens (negative means over-limit) |
ts | number | Timestamp of the projection (client time) |
config | ResolvedAlgorithm | The algorithm config for further calculations |
shard | number | Which shard was sampled |
ok | boolean | true when the projected request can be served by a shard. Permanent oversized denials can have a non-negative aggregate value. |
retryAt | number | undefined | Client timestamp when tokens become available. Infinity means no shard can serve the requested count. |
If you need precise projected values (for custom gauges), call check(ts, count).
Protection and deny lists
When enableProtection is on, the limiter tracks repeated failures per identifier, IP, user-agent, and country. Once a value reaches denyListThreshold within a rolling 10 minute window, its block is cached for up to 24 hours without checking the database. Failures paced wider than that window decay instead of accumulating, so a shared NAT or carrier IP is not blocked by unrelated users' failures spread over days.
Protection state is a bounded in-memory LRU. It evicts failure histories before blocks and refreshes blocks that keep sending requests. Under more than 4,096 simultaneous blocked values per prefix, the coldest block can be evicted; that value falls through to the normal database-backed rate limiter.
A successful request clears the counter for the identifier only. IP and user-agent counters survive, because they are attacker-supplied: clearing them on success would let a caller loop denyListThreshold - 1 failures plus one success forever, and would let anyone reset another client's counter by forging their user-agent.
You can also provide static deny lists to block known bad actors immediately.
const limiter = new Ratelimit({
db: ctx.db,
prefix: 'api',
limiter: Ratelimit.fixedWindow(100, '1 m'),
failureMode: 'closed',
enableProtection: true,
denyListThreshold: 30,
denyList: {
identifiers: ['known-bad-user-id'],
ips: ['203.0.113.0'],
userAgents: ['BadBot/1.0'],
countries: ['XX'],
},
});To trigger deny-list matching on request metadata, pass ip, userAgent, or country in the limit() call. In mutation code, prefer Convex request metadata:
const { ip, userAgent } = await ctx.meta.getRequestMetadata();
const result = await limiter.limit(userId, {
ip: ip ?? undefined,
userAgent: userAgent ?? undefined,
});For HTTP actions, read the Request headers directly:
const result = await limiter.limit(userId, {
ip: request.headers.get('x-forwarded-for') ?? undefined,
userAgent: request.headers.get('user-agent') ?? undefined,
country: request.headers.get('x-country') ?? undefined,
});Important: Deny-list state is in-memory and non-durable. It can survive across warm runtime requests, but is lost on cold starts/deploys. For persistent blocking, use an external deny list or database-backed blocklist.
Tracked values are capped at 4096 per prefix with least-recently-hit eviction, and values longer than 128 characters are stored truncated, so forged User-Agent headers cannot grow the map without bound. Counter increments happen in module memory and are not rolled back when Convex retries a mutation after a write conflict, so a value can count slightly more failures than it was actually served.
Dynamic limits
Dynamic limits let you change rate limits at runtime — useful for feature flags, admin overrides, or gradual rollouts. Enable them with dynamicLimits: true in the constructor.
const limiter = new Ratelimit({
db: ctx.db,
prefix: 'api:search',
limiter: Ratelimit.fixedWindow(100, '1 m'),
dynamicLimits: true,
});Then use setDynamicLimit to override the configured limit at runtime:
// Double the limit during a sale
await limiter.setDynamicLimit({ limit: 200 });
// Read the current override
const { dynamicLimit } = await limiter.getDynamicLimit();
// dynamicLimit === 200
// Remove the override (reverts to configured limit)
await limiter.setDynamicLimit({ limit: false });The dynamic limit overrides the limit field of the algorithm. For token bucket, it overrides refillRate (and maxTokens if they were originally equal). Overrides must be positive, finite budgets that every configured shard can serve. Setting or clearing an override advances the limiter's cache generation, invalidates snapshots and ephemeral block decisions, and prevents older in-flight operations from restoring them.
Limits and mitigations you should know
Important: This is application-layer limiting. It protects business logic and expensive downstream work, but it is not a network firewall or DDoS shield.
Recommended production posture:
- Enforce auth early and reject fast.
- Protect anonymous flows with captcha + validated session IDs.
- Put network-layer controls (Cloudflare or equivalent) in front when IP-based mitigation is required.
- Alert on request spikes and fail safely (
failureMode: "closed"by default).
API Reference
Constructor options
Create a Ratelimit instance with a config object:
const limiter = new Ratelimit(config: RatelimitConfig);| Option | Type | Default | Description |
|---|---|---|---|
db | ctx.db | — | Convex database context. Required for limit, check, getRemaining, getValue, resetUsedTokens, setDynamicLimit, getDynamicLimit. Not needed for hookAPI() (it receives db from the query/mutation context). |
limiter | ResolvedAlgorithm | — | Required. Algorithm created by Ratelimit.fixedWindow(), Ratelimit.slidingWindow(), or Ratelimit.tokenBucket(). |
prefix | string | 'kitcn/ratelimit' | Namespaces stored state in the database. Use unique prefixes for different rate limit scopes. |
dynamicLimits | boolean | false | Enables setDynamicLimit() / getDynamicLimit(). |
failureMode | 'closed' | 'open' | 'closed' | Behavior on timeout. 'closed' rejects, 'open' allows. |
timeout | number | 5000 | Milliseconds before triggering failureMode behavior. |
enableProtection | boolean | false | Enables deny-list tracking on repeated failures. |
denyListThreshold | number | 30 | Failures inside a rolling 10-minute window before a value is cached as blocked for up to 24 hours. Requires enableProtection: true. |
denyList | ProtectionLists | undefined | Static deny lists. See Protection and deny lists. |
ephemeralCache | Map<string, number> | false | new Map() | In-memory block cache. Shared across requests in the same Convex invocation. Pass false to disable. |
Algorithm builders
All builders are available as static methods on Ratelimit.
Ratelimit.fixedWindow(limit, window, options?)
fixedWindow(limit: number, window: Duration, options?: AlgorithmOptions): FixedWindowAlgorithm| Parameter | Type | Description |
|---|---|---|
limit | number | Tokens replenished per window |
window | Duration | Window length (number in ms, or string like '1 m') |
options.shards | number | Write distribution shards (default 1). The budget is dealt across shards. |
options.maxReserved | number | Max tokens that can be borrowed from future windows |
options.capacity | number | Max stored tokens (default = limit) |
options.start | number | Epoch offset for window alignment |
Ratelimit.slidingWindow(limit, window, options?)
slidingWindow(limit: number, window: Duration, options?: AlgorithmOptions): SlidingWindowAlgorithm| Parameter | Type | Description |
|---|---|---|
limit | number | Max requests in the sliding window |
window | Duration | Window length |
options.shards | number | Write distribution shards (default 1). The budget is dealt across shards. |
options.maxReserved | number | Max tokens that can be borrowed |
Note: reserve is not supported with sliding window. The algorithm needs both current and previous window counts, which makes reservation impractical.
Ratelimit.tokenBucket(refillRate, interval, maxTokens, options?)
tokenBucket(refillRate: number, interval: Duration, maxTokens: number, options?: AlgorithmOptions): TokenBucketAlgorithm| Parameter | Type | Description |
|---|---|---|
refillRate | number | Tokens added per interval |
interval | Duration | Refill interval |
maxTokens | number | Maximum bucket capacity |
options.shards | number | Write distribution shards (default 1). The budget is dealt across shards. |
options.maxReserved | number | Max tokens that can be borrowed from future refills |
Core methods
limit(identifier, options?)
Consume tokens and return a response. This is the primary method for enforcing rate limits.
limit(identifier: string, options?: LimitRequest): Promise<RatelimitResponse>check(identifier, options?)
Evaluate without consuming tokens. Use this for read-only checks (e.g. showing a warning before the user submits). It runs the same evaluation as limit() for the requested count, so check() and a following limit() agree — it just never writes state.
check(identifier: string, options?: CheckRequest): Promise<RatelimitResponse>getRemaining(identifier)
Return the remaining tokens, reset time, and limit for an identifier. It reads every raw shard at one common timestamp and sums each shard's independently usable whole tokens. It never lets a full shard absorb another shard's refill, nets reserved debt against an open peer, or combines unusable fractions across isolated shards.
getRemaining(identifier: string): Promise<RemainingResponse>getValue(identifier, options?)
Return a raw snapshot for custom projections and UI calculations.
getValue(identifier: string, options?: { sampleShards?: number }): Promise<RatelimitSnapshot>resetUsedTokens(identifier)
Clear all stored state for an identifier. Useful for admin resets.
resetUsedTokens(identifier: string): Promise<void>setDynamicLimit(options)
Override the configured limit at runtime. Pass { limit: false } to remove the override. Requires dynamicLimits: true.
setDynamicLimit(options: { limit: number | false }): Promise<void>getDynamicLimit()
Read the current dynamic override. Returns { dynamicLimit: number | null }. Requires dynamicLimits: true.
getDynamicLimit(): Promise<DynamicLimitResponse>hookAPI(options?)
Export a getRatelimit query and getServerTime mutation for the React hook.
hookAPI(options?: HookAPIOptions): {
getRatelimit: FunctionReference<'query'>;
getServerTime: FunctionReference<'mutation'>;
}Request options
LimitRequest
Pass these options to limit() to customize behavior per-call.
| Field | Type | Default | Description |
|---|---|---|---|
rate | number | 1 | Alias for count. Tokens to consume. |
count | number | 1 | Tokens to consume. Takes precedence if both rate and count are set. |
reserve | boolean | false | Allow borrowing from future capacity. maxReserved caps the debt when configured; otherwise headroom is uncapped. Not supported by slidingWindow. |
ip | string | — | IP address for deny-list matching |
userAgent | string | — | User-agent for deny-list matching |
country | string | — | Country code for deny-list matching |
geo | unknown | — | Reserved for future geo-based rules |
CheckRequest
Same fields as LimitRequest. count / rate set how many tokens the check is evaluated against, and reserve decides whether reserved capacity counts as available. Nothing is written, because check() is read-only.
Response types
RatelimitResponse
Returned by limit() and check().
| Field | Type | Description |
|---|---|---|
success | boolean | true if the request was allowed |
ok | boolean | Alias for success (Convex DX parity) |
limit | number | Maximum tokens for this algorithm |
remaining | number | Tokens left after this request (floored to 0). With shards > 1 this is extrapolated from the stored capacity of the shard that served the request, so it is an estimate — use getRemaining() for an exact count. |
reset | number | Epoch ms when tokens will be available. Denied reserved fixed-window and token-bucket requests use the point where the request fits within maxReserved, not full debt recovery. Permanently oversized requests return 0. |
pending | Promise<unknown> | Resolves when async side-effects complete |
reason | 'timeout' | 'cacheBlock' | 'denyList' | 'requestTooLarge' | Present when a reason applies. requestTooLarge means no shard can ever serve the requested count; smaller unusable shards do not contribute retry deadlines. Reduce count, reduce shards, or raise capacity. Note: failureMode: 'open' can return success: true with reason: 'timeout'. |
deniedValue | string | Present only when reason === 'denyList'. The value that triggered the block. |
RemainingResponse
Returned by getRemaining().
| Field | Type | Description |
|---|---|---|
remaining | number | Tokens available |
reset | number | Epoch ms of next replenishment |
limit | number | Maximum tokens |
RatelimitSnapshot
Returned by getValue(). Used for custom projections and the React hook.
| Field | Type | Description |
|---|---|---|
value | number | Tokens left at one common read timestamp across the stored capacity. Sampled shards are summed, then scaled up by the capacity share they cover |
ts | number | Timestamp of last state update |
shard | number | The sampled shard holding the most tokens |
config | ResolvedAlgorithm | Full algorithm config for calculateRatelimit() |
state | RatelimitState | Projected aggregate plus every sampled shard state. Sliding windows retain current and previous counts with both timestamps. All-shard snapshots preserve independent saturation and decay in later projections; partial samples remain estimates. |
Pass a snapshot through snapshotToState() before handing it to calculateRatelimit(), which expects stored state rather than remaining tokens.
Hook API
HookAPIOptions
Options for hookAPI().
| Field | Type | Default | Description |
|---|---|---|---|
identifier | string | (ctx, fromClient?) => string | Promise<string> | — | How to resolve the identifier. A string uses it directly. A callback receives the Convex context and the optional client-provided identifier. |
sampleShards | number | 1 | How many shards to sample when reading. Higher = more accurate, more reads. |
UseRatelimitOptions
Options for the useRatelimit() React hook.
useRatelimit(
getRatelimitValueQuery: FunctionReference<'query'> | string,
options?: UseRatelimitOptions
)| Field | Type | Default | Description |
|---|---|---|---|
identifier | string | — | Passed to the getRatelimit query |
count | number | 1 | Tokens to project for status calculation |
sampleShards | number | — | Override sampleShards from hook API |
getServerTimeMutation | FunctionReference | string | — | Enables clock-skew correction between client and server |
Time constants
Pre-defined millisecond constants exported from kitcn/ratelimit:
| Constant | Value |
|---|---|
SECOND | 1_000 |
MINUTE | 60_000 |
HOUR | 3_600_000 |
DAY | 86_400_000 |
WEEK | 604_800_000 |
Internal tables
The rate limiter stores state in three local schema keys. These come from your local convex/lib/plugins/ratelimit/schema.ts extension, so do not define these keys twice. The underlying Convex storage table names stay underscored.
| Schema key | Purpose |
|---|---|
ratelimitState | Per-identifier, per-shard token state |
ratelimitDynamicLimit | Dynamic limit overrides per prefix |
ratelimitProtectionHit | Protection tracking (hits, blocks) per prefix |
Advanced notes
calculateRatelimit
The calculateRatelimit function is exported for custom projections and UI calculations. It takes a state snapshot, algorithm config, current timestamp, and count, and returns the evaluated result without touching the database.
import { calculateRatelimit } from 'kitcn/ratelimit';
const result = calculateRatelimit(
{ value: 8, ts: Date.now() - 30_000 },
Ratelimit.fixedWindow(10, '1 m'),
Date.now(),
1
);
// result.remaining, result.reset, result.retryAfterresult.remaining is floored to 0; result.remainingRaw keeps the exact value and goes negative when the request overdraws.
snapshotToState
getValue() reports tokens left, while calculateRatelimit() takes stored state — the two differ for sliding windows, which store current and previous used counts. The snapshot retains the projected aggregate and sampled shard states, and snapshotToState returns them for later calculations. Sampling every shard preserves independent capacity saturation and sliding-window decay; partial samples remain estimates.
import { calculateRatelimit, snapshotToState } from 'kitcn/ratelimit';
const snapshot = await limiter.getValue('user_123');
const result = calculateRatelimit(
snapshotToState(snapshot),
snapshot.config,
Date.now(),
1
);Sharding
When shards > 1, the configured budget is dealt across the shards and each limit() call picks a random shard (or two, using power-of-two-choices when shards >= 3) to reduce write contention.
The shares add back up to the budget you configured, so fixedWindow(20, '1 m', { shards: 8 }) still grants 20 requests per minute. Budgets that do not divide evenly hand the remainder to the low-numbered shards — limit: 5 over two shards becomes 3 and 2, not 2.5 each, because a shard can only spend whole tokens. Fractional totals deal their whole portion first and retain the fraction on one shard. Whole-token maxReserved headroom is dealt the same way. Token buckets allocate refillRate in proportion to each shard's maxTokens share, so uneven capacities refill without clipping the configured total.
Two trade-offs come with it:
getValue()samples a subset of shards and scales the result up, so partial samples are estimates. Sampling every shard preserves independent state for exact later projections.getRemaining()reads every shard and is exact.- A single call spends from one shard, so
countcan never exceed that shard's share. Keep the per-shard share comfortably above your largestcount— ten or more is a good target. The algorithm builders throw whenlimit / shards(orcapacity / shards, ormaxTokens / shardsfortokenBucket) drops below1, since a shard holding less than one token cannot serve a request. - The limiter tries its preferred shard candidates first, then reads untouched candidates concurrently before denying a request. Extra reads happen only when the preferred candidates cannot serve it.
For most use cases, shards: 1 (the default) is fine. Increase shards only when you see write contention on hot identifiers.
Ephemeral cache
The ephemeral block cache is an in-memory Map<string, number> that caches "blocked until" timestamps per shard, requested count, and reservation mode. When a shard fails, equivalent calls skip its database read until the block expires and try another shard; smaller or reserved requests still probe it. A request is cache-blocked only when every shard it could use is blocked, and reset is the earliest retry across cached and freshly evaluated shards. Cache writes prune expired variants, skip permanent infinite resets, and retain at most 32 variants per identifier. The cache is per-Ratelimit instance and resets on each Convex function invocation. Pass ephemeralCache: false to disable it, or pass a shared Map across multiple Ratelimit instances to share the cache.
ok alias
The response includes both success and ok. They are always identical. ok exists for Convex DX parity with patterns like if (!result.ok) throw ....