@convex-dev/rate-limiter

中文文档
0.4.3发布于 2026/10/30Apache-2.0TypeScriptES Module近 30 天下载 301.3万

A rate limiter component for Convex. Define and use application-layer rate limits. Type-safe, transactional, fair, safe, and configurable sharding to scale.

原始使用文档
文档目录20 节
  1. Pre-requisite: Convex
  2. Installation
  3. Define your rate limits:
  4. Strategies:
  5. Usage
  6. Using a simple global rate limit:
  7. Per-user rate limit:
  8. Consume a custom count
  9. Throw automatically
  10. Check a rate limit without consuming it
  11. Credit capacity back to a rate limit
  12. Reset a rate limit
  13. Define a rate limit inline / dynamically
  14. Using the React hook
  15. Fetching the current value directly
  16. Avoiding contention with asynchronous updates
  17. Scaling rate limiting with shards
  18. Reserving capacity:
  19. Adding jitter
  20. More resources

Convex Rate Limiter Component

npm version

This component provides application-level rate limiting.

Teaser:

const rateLimiter = new RateLimiter(components.rateLimiter, {
  freeTrialSignUp: { kind: "fixed window", rate: 100, period: HOUR },
  sendMessage: { kind: "token bucket", rate: 10, period: MINUTE, capacity: 3 },
});

// Restrict how fast free users can sign up to deter bots
const status = await rateLimiter.limit(ctx, "freeTrialSignUp");

// Limit how fast a user can send messages
const status = await rateLimiter.limit(ctx, "sendMessage", { key: userId });

// Use the React hook to check the rate limit
const { status, check } = useRateLimit(api.example.getRateLimit, { count });

See below for more details on usage.

What is rate limiting?

Rate limiting is the technique of controlling how often actions can be performed, typically on a server. There are a host of options for achieving this, most of which operate at the network layer.

What is application-layer rate limiting?

Application-layer rate limiting happens in your app's code where you are handling authentication, authorization, and other business logic. It allows you to define nuanced rules, and enforce policies more fairly. It is not the first line of defense for a sophisticated DDOS attack (which thankfully are extremely rare), but will serve most real-world use cases.

What differentiates this approach?

  • Type-safe usage: you won't accidentally misspell a rate limit name.
  • Configurable for fixed window or token bucket algorithms.
  • Efficient storage and compute: storage is not proportional to requests.
  • Configurable limits that apply updates in the background for scalability.
  • Transactional evaluation: all rate limit changes will roll back if your mutation fails.
  • Fairness guarantees via credit "reservation": save yourself from exponential backoff.
  • Opt-in "rollover" or "burst" allowance via a configurable capacity.
  • Fails closed, not open: avoid cascading failure when traffic overwhelms your rate limits.

See the associated Stack post for more details and background.

Pre-requisite: Convex

You'll need an existing Convex project to use the component. Convex is a hosted backend platform, including a database, serverless functions, and a ton more you can learn about here.

Run npm create convex or follow any of the quickstarts to set one up.

Installation

Install the component package:

npm install @convex-dev/rate-limiter

Create a convex.config.ts file in your app's convex/ folder and install the component by calling use:

// convex/convex.config.ts
import { defineApp } from "convex/server";
import rateLimiter from "@convex-dev/rate-limiter/convex.config.js";

const app = defineApp();
app.use(rateLimiter);

export default app;

Define your rate limits:

import { RateLimiter, MINUTE, HOUR } from "@convex-dev/rate-limiter";
import { components } from "./_generated/api";

const rateLimiter = new RateLimiter(components.rateLimiter, {
  // One global / singleton rate limit, using a "fixed window" algorithm.
  freeTrialSignUp: { kind: "fixed window", rate: 100, period: HOUR },
  // A per-user limit, allowing one every ~6 seconds.
  // Allows up to 3 in quick succession if they haven't sent many recently.
  sendMessage: { kind: "token bucket", rate: 10, period: MINUTE, capacity: 3 },
  failedLogins: { kind: "token bucket", rate: 10, period: HOUR },
  // Apply updates asynchronously for eventual consistency.
  llmTokenBudget: {
    kind: "token bucket",
    rate: 10,
    period: HOUR,
    applyUpdates: "asynchronously",
  },
  // Use sharding to increase throughput without compromising on correctness.
  llmTokens: { kind: "token bucket", rate: 40000, period: MINUTE, shards: 10 },
  llmRequests: { kind: "fixed window", rate: 1000, period: MINUTE, shards: 10 },
});
  • You can safely generate multiple instances if you want to define different rates in separate places, provided the keys don't overlap.
  • The units for period are milliseconds. MINUTE above is 60000.

Strategies:

The token bucket approach provides guarantees for overall consumption via the rate per period at which tokens are added, while also allowing unused tokens to accumulate (like "rollover" minutes) up to some capacity value. So if you could normally send 10 per minute, with a capacity of 20, then every two minutes you could send 20, or if in the last two minutes you only sent 5, you can send 15 now.

The fixed window approach differs in that the tokens are granted all at once, every period milliseconds. It similarly allows accumulating "rollover" tokens up to a capacity (defaults to the rate for both rate limit strategies). You can specify a custom start time if e.g. you want the period to reset at a specific time of day. By default it will be random to help space out requests that are retrying.

Usage

Using a simple global rate limit:

const { ok, retryAfter } = await rateLimiter.limit(ctx, "freeTrialSignUp");
  • ok is whether it successfully consumed the resource
  • retryAfter is when it would have succeeded in the future.

Note: If you have many clients using the retryAfter to decide when to retry, defend against a thundering herd by adding some jitter. Or use the reserve functionality discussed below.

Per-user rate limit:

Use key to use a rate limit specific to some user / team / session ID / etc.

const status = await rateLimiter.limit(ctx, "sendMessage", { key: userId });

Consume a custom count

By default, each call to limit counts as one unit. Pass count to customize.

// Consume multiple in one request to prevent rate limits on an LLM API.
const status = await rateLimiter.limit(ctx, "llmTokens", { count: tokens });

Throw automatically

By default it will return { ok, retryAfter }. To have it throw automatically when the limit is exceeded, use throws. It throws a ConvexError with RateLimitError data (data: {kind, name, retryAfter}) instead of returning when ok is false.

// Automatically throw an error if the rate limit is hit
await rateLimiter.limit(ctx, "failedLogins", { key: userId, throws: true });

Check a rate limit without consuming it

const status = await rateLimiter.check(ctx, "failedLogins", { key: userId });

Credit capacity back to a rate limit

// Give back the token that was consumed for a request that failed.
await rateLimiter.credit(ctx, "sendMessage", { key: userId });

// Credit a custom count, e.g. to settle a reservation made up-front.
await rateLimiter.credit(ctx, "llmTokens", { count: reserved - used });

It is capped at its capacity (defaults to rate). Any excess is discarded.

For a sharded rate limit, two random shards are selected and the credit is split between them so that they end up with as close to the same value as possible. Any leftover credit is discarded.

Reset a rate limit

// Reset a rate limit on successful login
await rateLimiter.reset(ctx, "failedLogins", { key: userId });

Define a rate limit inline / dynamically

// Use a one-off rate limit config (when not named on initialization)
const config = { kind: "fixed window", rate: 1, period: SECOND };
const status = await rateLimiter.limit(ctx, "oneOffName", { config });

Using the React hook

You can use the React hook to check the rate limit in your browser code.

First, define the server API to get the rate limit value:

// In convex/example.ts
export const { getRateLimit, getServerTime } = rateLimiter.hookAPI(
  "sendMessage",
  {
    // Optionally provide a key function to get the key for the rate limit
    key: async (ctx) => await getUserId(ctx),
    // To allow the client to provide the key, pass a function that takes the key from the client
    key: async (ctx, keyFromClient) => {
      await ensureUserCanUseKey(ctx, keyFromClient);
      return keyFromClient;
    },
  },
);

Then, use the React hook to check the rate limit:

function App() {
  const { status: { ok, retryAt }, check } = useRateLimit(api.example.getRateLimit, {
    // [recommended] Allows the hook to sync the browser and server clocks
    getServerTimeMutation: getServerTime,
    // [optional] The number of tokens to wait on
    count: 1,
  });

  // If you want to check at specific times and get the concrete value:
  const { value, ts, config, ok, retryAt } = check(Date.now(), count);

Fetching the current value directly

You can fetch the current value of a rate limit directly, if you want to know the concrete value and timestamp it was last updated.

const { config, value, ts } = await rateLimiter.getValue(ctx, "sendMessage", {
  key: userId,
});

And you can use calculateRateLimit to calculate the value at a given timestamp:

import { calculateRateLimit } from "@convex-dev/rate-limiter";

const { config, value, ts } = calculateRateLimit(
  { value, ts },
  config,
  Date.now(),
  count || 0,
);

Avoiding contention with asynchronous updates

When many requests are happening at once, they can all be trying to modify the same values in the database. Because Convex provides strong transactions, they will never overwrite each other, so you don't have to worry about the rate limiter succeeding more often than it should. However, when there is high contention for these values, it causes optimistic concurrency control conflicts. Convex automatically retries these a number of times with backoff, but it's still best to avoid them.

Using the async mode of the rate limiter can help increase the throughput of the rate limiter while avoiding OCC conflicts. In the RateLimiter config, set the limit's applyUpdates to "asynchronously":

const rateLimiter = new RateLimiter(components.rateLimiter, {
  llmTokens: {
    kind: "token bucket",
    rate: 40000,
    period: MINUTE,
    applyUpdates: "asynchronously",
  },
});

Instead of updating rate limits transactionally, updates are placed in a queue and applied in the background by a Batch Worker. Checking the limit inside a mutation reads from a stale database snapshot, so these rate limit calls will never conflict with concurrent writers.

The trade-off is that asynchronous limits are eventually consistent. A large burst of concurrent callers can all succeed before the worker applies enough consumption to block new callers, which can cause the limit to go negative. Note that the consumption is still accounted for. When asynchronous updates exceed capacity, it goes negative and future requests will be rejected until enough time has passed to recover from the negative balance.

If enforcing an exact instantaneous cap is required for your use case, consider using shards.

Scaling rate limiting with shards

One option to increase throughput while maintaining transactionality is to "shard" the rate limit. We break up the total capacity into individual buckets, or "shards". When we go to use some of that capacity, we check a random shard.1 While sometimes we'll get unlucky and get rate limited when there was capacity elsewhere, we'll never violate the rate limit's upper bound.

const rateLimiter = new RateLimiter(components.rateLimiter, {
  llmTokens: { kind: "token bucket", rate: 40000, period: MINUTE, shards: 10 },
  llmRequests: { kind: "fixed window", rate: 1000, period: MINUTE, shards: 10 },
});

Here we're using 10 shards to handle 1,000 QPM. If you want some rough math to guess at how many shards to add, take the max queries per second you expect and divide by two. It's also useful for each shard to have five (ideally ten) or more capacity. In this case, we have ten (rate / shards) and don't expect normal traffic to exceed ~20 QPS.

Tip: If you want a rate like { rate: 100, period: SECOND } and you are flexible in the overall period, then you can shard this by increasing the rate and period proportionally to get enough shards and capacity per shard: { shards: 50, rate: 250, period: 2.5 * SECOND } or even better: { shards: 50, rate: 1000, period: 10 * SECOND }.

Note that an async rate limit cannot be sharded.

Power of two

We're actually going one step further and checking two shards and using the one with more capacity, to keep them relatively balanced, based on the power of two technique. We will also combine the capacity of the two shards if neither has enough on their own.

Resetting a sharded rate limit

Resetting a sharded rate limit will read and write to all of the shards, which may cause OCC conflicts if there are many concurent requests. If your use-case involves frequent resets, consider applying updates asynchronously instead of sharding.

Reserving capacity:

You can also allow it to reserve capacity to avoid starvation on larger requests.

When you reserve capacity ahead of time, the contract is that you can run your operation at the specified time (via the retryAfter field), at which point you don't have to re-check the rate limit. Your capacity has been "ear-marked".

With this, you can queue up many operations and they will be run at spaced-out intervals, maximizing the utilization of the rate limit.

Details in the Stack post.

const myAction = internalAction({
  args: {
    //...
    skipCheck: v.optional(v.boolean()),
  },
  handler: async (ctx, args) => {
    if (!args.skipCheck) {
      // Reserve future capacity instead of just failing now
      const status = await rateLimiter.limit(ctx, "llmRequests", {
        reserve: true,
      });
      if (status.retryAfter) {
        return ctx.scheduler.runAfter(
          status.retryAfter,
          internal.foo.myAction,
          {
            // When we run in the future, we can skip the rate limit check,
            // since we've just reserved that capacity.
            skipCheck: true,
          },
        );
      }
    }
    // do the operation
  },
});

Adding jitter

When too many users show up at once, it can cause network congestion, database contention, and consume other shared resources at an unnecessarily high rate. Instead we can return a random time within the next period to retry. Hopefully this is infrequent. This technique is referred to as adding "jitter."

A simple implementation could look like:

const retryAfter = status.retryAfter + Math.random() * period;

For the fixed window, we also introduce randomness by picking the start time of the window (from which all subsequent windows are based) randomly if config.start wasn't provided. This helps prevent all clients from flooding requests at midnight and paging you.

More resources

Check out a full example here.

See this article for more information on usage and advanced patterns, for example:

  • How the different rate limiting strategies work under the hood.
  • Using multiple rate limits in a single transaction.
  • Rate limiting anonymous users.

版本与发布记录

@convex-dev/rate-limiter 共发布 28 个版本,首次发布于 2024/10/22;过去 12 个月发布了 9 个版本。

版本发布日期距今
0.4.3latest2026/10/3011 天前
0.4.22026/10/101 个月前
0.4.12026/10/071 个月前
0.4.1-alpha.0alpha2026/09/241 个月前
0.4.02026/09/151 个月前
0.4.0-alpha.02026/09/121 个月前

关于 @convex-dev/rate-limiter 的常见问题

@convex-dev/rate-limiter 是什么?

A rate limiter component for Convex. Define and use application-layer rate limits. Type-safe, transactional, fair, safe, and configurable sharding to scale.

如何安装 @convex-dev/rate-limiter?

在项目目录运行 npm install @convex-dev/rate-limiter,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:convex(^1.43.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@convex-dev/rate-limiter 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@convex-dev/rate-limiter 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@convex-dev/rate-limiter 可以商用吗?

@convex-dev/rate-limiter 使用 Apache-2.0 许可。宽松许可:允许商业使用、修改和再分发,并附带明确的专利授权;分发时需保留许可与 NOTICE 文件,修改过的文件要注明改动。以上为概括,不构成法律意见,请以许可原文为准。

@convex-dev/rate-limiter 的下载量有多少?

根据 npm 官方下载统计,@convex-dev/rate-limiter 在 2026/10/11 – 2026/11/09 这 30 天内被下载 3,013,019 次,日均约 100,434 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@convex-dev/rate-limiter 还在维护吗?

最新版本 0.4.3 发布于 2026/10/30(11 天前);至今共发布 28 个版本,其中过去 12 个月发布了 9 个;首次发布于 2024/10/22。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。