kitcn

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 getWhy it matters
Upstash-style APIEasier migration if your team already knows Upstash (limit, check, getRemaining, resetUsedTokens)
Convex-first tablesNo component registration and no component migration path to manage
Read dedupe helpersCommon repeated reads may reuse cached results and reduce duplicate DB fetches
React hook supporthookAPI() + useRatelimit() gives accurate client countdown and button states
Fail-closed defaultSafer behavior under pressure (failureMode: "closed")

Install

npm install kitcn
pnpm add kitcn
yarn add kitcn
bun add kitcn

Scaffold the starter (required)

Rate limiting is opt-in, so scaffold the full starter once:

npx kitcn add ratelimit

That creates:

  • convex/lib/plugins/ratelimit/schema.ts
  • convex/lib/plugins/ratelimit/plugin.ts
  • convex/functions/plugins/ratelimit.ts

and registers ratelimitExtension() in convex/functions/schema.ts.

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.

convex/lib/plugins/ratelimit/plugin.ts
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 off publicMutation, 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

OptionTypeDescription
bucketsRecord<bucket, Record<tier, ResolvedAlgorithm>>Required. Named buckets, each mapping a tier to an algorithm.
getBucket({ ctx, meta }) => bucketRequired. Picks the bucket, usually from meta.ratelimit.
getUser({ ctx, meta }) => userRequired. Resolves the caller.
getTier(user) => tierRequired. Picks the tier within the bucket.
getSignals({ ctx, meta, user, bucket, tier }) => LimitRequest | undefinedRequired. Resolved once per request, before getIdentifier.
getIdentifier({ ctx, meta, user, bucket, tier, signals }) => stringRequired. The partition key.
prefixstring | (args) => stringNamespaces stored state. Defaults to ratelimit:<bucket>:<tier>.
messagestring | (args) => stringMessage 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.

convex/lib/crpc.ts
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.

OptionTypeDefaultDescription
shardsnumber1Number 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).
maxReservednumberundefinedFinite, 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.
capacitynumberlimitMaximum 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.
startnumber0Epoch 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.

UnitMeaningExample
msmilliseconds'500 ms'
sseconds'30 s'
mminutes'1 m'
hhours'1 h'
ddays'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 ms

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

convex/functions/ratelimit.ts
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:

src/components/send-button.tsx
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 /ratelimit demo uses.
  • a generated FunctionReference from api.

The hook returns:

FieldTypeDescription
statusHookStatus | undefinedundefined 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 | undefinedManual 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:

FieldTypeDescription
valuenumberProjected remaining tokens (negative means over-limit)
tsnumberTimestamp of the projection (client time)
configResolvedAlgorithmThe algorithm config for further calculations
shardnumberWhich shard was sampled
okbooleantrue when the projected request can be served by a shard. Permanent oversized denials can have a non-negative aggregate value.
retryAtnumber | undefinedClient 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);
OptionTypeDefaultDescription
dbctx.dbConvex database context. Required for limit, check, getRemaining, getValue, resetUsedTokens, setDynamicLimit, getDynamicLimit. Not needed for hookAPI() (it receives db from the query/mutation context).
limiterResolvedAlgorithmRequired. Algorithm created by Ratelimit.fixedWindow(), Ratelimit.slidingWindow(), or Ratelimit.tokenBucket().
prefixstring'kitcn/ratelimit'Namespaces stored state in the database. Use unique prefixes for different rate limit scopes.
dynamicLimitsbooleanfalseEnables setDynamicLimit() / getDynamicLimit().
failureMode'closed' | 'open''closed'Behavior on timeout. 'closed' rejects, 'open' allows.
timeoutnumber5000Milliseconds before triggering failureMode behavior.
enableProtectionbooleanfalseEnables deny-list tracking on repeated failures.
denyListThresholdnumber30Failures inside a rolling 10-minute window before a value is cached as blocked for up to 24 hours. Requires enableProtection: true.
denyListProtectionListsundefinedStatic deny lists. See Protection and deny lists.
ephemeralCacheMap<string, number> | falsenew 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
ParameterTypeDescription
limitnumberTokens replenished per window
windowDurationWindow length (number in ms, or string like '1 m')
options.shardsnumberWrite distribution shards (default 1). The budget is dealt across shards.
options.maxReservednumberMax tokens that can be borrowed from future windows
options.capacitynumberMax stored tokens (default = limit)
options.startnumberEpoch offset for window alignment

Ratelimit.slidingWindow(limit, window, options?)

slidingWindow(limit: number, window: Duration, options?: AlgorithmOptions): SlidingWindowAlgorithm
ParameterTypeDescription
limitnumberMax requests in the sliding window
windowDurationWindow length
options.shardsnumberWrite distribution shards (default 1). The budget is dealt across shards.
options.maxReservednumberMax 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
ParameterTypeDescription
refillRatenumberTokens added per interval
intervalDurationRefill interval
maxTokensnumberMaximum bucket capacity
options.shardsnumberWrite distribution shards (default 1). The budget is dealt across shards.
options.maxReservednumberMax 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.

FieldTypeDefaultDescription
ratenumber1Alias for count. Tokens to consume.
countnumber1Tokens to consume. Takes precedence if both rate and count are set.
reservebooleanfalseAllow borrowing from future capacity. maxReserved caps the debt when configured; otherwise headroom is uncapped. Not supported by slidingWindow.
ipstringIP address for deny-list matching
userAgentstringUser-agent for deny-list matching
countrystringCountry code for deny-list matching
geounknownReserved 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().

FieldTypeDescription
successbooleantrue if the request was allowed
okbooleanAlias for success (Convex DX parity)
limitnumberMaximum tokens for this algorithm
remainingnumberTokens 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.
resetnumberEpoch 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.
pendingPromise<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'.
deniedValuestringPresent only when reason === 'denyList'. The value that triggered the block.

RemainingResponse

Returned by getRemaining().

FieldTypeDescription
remainingnumberTokens available
resetnumberEpoch ms of next replenishment
limitnumberMaximum tokens

RatelimitSnapshot

Returned by getValue(). Used for custom projections and the React hook.

FieldTypeDescription
valuenumberTokens left at one common read timestamp across the stored capacity. Sampled shards are summed, then scaled up by the capacity share they cover
tsnumberTimestamp of last state update
shardnumberThe sampled shard holding the most tokens
configResolvedAlgorithmFull algorithm config for calculateRatelimit()
stateRatelimitStateProjected 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().

FieldTypeDefaultDescription
identifierstring | (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.
sampleShardsnumber1How 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
)
FieldTypeDefaultDescription
identifierstringPassed to the getRatelimit query
countnumber1Tokens to project for status calculation
sampleShardsnumberOverride sampleShards from hook API
getServerTimeMutationFunctionReference | stringEnables clock-skew correction between client and server

Time constants

Pre-defined millisecond constants exported from kitcn/ratelimit:

ConstantValue
SECOND1_000
MINUTE60_000
HOUR3_600_000
DAY86_400_000
WEEK604_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 keyPurpose
ratelimitStatePer-identifier, per-shard token state
ratelimitDynamicLimitDynamic limit overrides per prefix
ratelimitProtectionHitProtection 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.retryAfter

result.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 count can never exceed that shard's share. Keep the per-shard share comfortably above your largest count — ten or more is a good target. The algorithm builders throw when limit / shards (or capacity / shards, or maxTokens / shards for tokenBucket) drops below 1, 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 ....

Next steps

On this page