Back

A Simple Rate Limiter with Convex

4 min read
Convex already provides @convex-dev/rate-limiter, an installable component with fixed-window and token-bucket limits. It is the better choice for most apps. The custom version below is mainly useful for learning or for a small, app-specific rule.

In one of my pet projects, which uses Convex as its backend, I needed a simple way to prevent users from resending a verification email more than once every five minutes and to add a cooldown to password-reset requests. Instead of installing a ready-made solution, I decided to build a small limiter myself to understand how it could work with Convex. Here is my implementation.

Choose a trusted subject

The limiter works best when its subject is a trusted, stable identifier, such as:

  • Authenticated user ID
  • Organization or community ID
  • API key
  • Subscription ID
  • Verified email
  • Server-derived IP address

How it works

  • scope identifies the operation, such asemail_verification_resend.
  • subject identifies who or what is limited, such as an authenticated user ID.
  • minIntervalMs prevents rapid repetition.
  • maxAttempts and windowMs set the longer quota.

The scope keeps limits independent, so exporting a report does not consume the same user's verification-email quota.

Create the rate-limit table

Add a table with an index for the scope and subject. The timestamps track the window and the most recent accepted attempt.

typescript
// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  rateLimits: defineTable({
    scope: v.string(),
    subject: v.string(),
    windowStartedAt: v.number(),
    attempts: v.number(),
    lastAttemptAt: v.number(),
  }).index("by_scope_subject", ["scope", "subject"]),
});

Add the rate-limit helper

The helper checks the cooldown first, including across window boundaries. It then starts a fresh window or checks the current quota. Rejected calls do not change the counter or extend the cooldown.

typescript
// convex/lib/rateLimit.ts
import { ConvexError } from "convex/values";
import type { MutationCtx } from "../_generated/server";

type RateLimitOptions = {
  scope: string;
  subject: string;
  maxAttempts: number;
  windowMs: number;
  minIntervalMs?: number;
};

export async function enforceRateLimit(
  ctx: MutationCtx,
  options: RateLimitOptions,
) {
  const now = Date.now();
  const existing = await ctx.db
    .query("rateLimits")
    .withIndex("by_scope_subject", (q) =>
      q.eq("scope", options.scope).eq("subject", options.subject),
    )
    .unique();

  if (
    existing &&
    options.minIntervalMs !== undefined &&
    now - existing.lastAttemptAt < options.minIntervalMs
  ) {
    throw new ConvexError({
      code: "RATE_LIMITED",
      message: "Please wait a moment before trying again.",
    });
  }

  if (!existing || now - existing.windowStartedAt >= options.windowMs) {
    const freshBucket = {
      attempts: 1,
      windowStartedAt: now,
      lastAttemptAt: now,
    };

    if (existing) {
      await ctx.db.patch(existing._id, freshBucket);
    } else {
      await ctx.db.insert("rateLimits", {
        scope: options.scope,
        subject: options.subject,
        ...freshBucket,
      });
    }
    return;
  }

  if (existing.attempts >= options.maxAttempts) {
    throw new ConvexError({
      code: "RATE_LIMITED",
      message: "Too many attempts. Please try again later.",
    });
  }

  await ctx.db.patch(existing._id, {
    attempts: existing.attempts + 1,
    lastAttemptAt: now,
  });
}

Use it for verification emails

This mutation allows one resend every five minutes and five per hour. Its subject comes from Convex authentication, so the client cannot switch IDs to avoid the limit. Because sending email calls an external service, the mutation schedules an existing internal email action.

typescript
// convex/verification.ts
import { internal } from "./_generated/api";
import { mutation } from "./_generated/server";
import { ConvexError, v } from "convex/values";
import { enforceRateLimit } from "./lib/rateLimit";

export const resendEmail = mutation({
  args: {},
  returns: v.null(),
  handler: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity();

    if (!identity) {
      throw new ConvexError("You must be signed in.");
    }

    await enforceRateLimit(ctx, {
      scope: "email_verification_resend",
      subject: identity.subject,
      minIntervalMs: 5 * 60 * 1000,
      maxAttempts: 5,
      windowMs: 60 * 60 * 1000,
    });

    await ctx.scheduler.runAfter(
      0,
      internal.verification.sendVerificationEmail,
      { authSubject: identity.subject },
    );

    return null;
  },
});

Other useful examples

The helper stays the same. Only the scope, subject, and policy change.

  • Password resets: verified email, once every five minutes
  • Report exports: authenticated user ID, once per minute and 10 per day
  • AI generation: subscription ID, 20 per hour
  • Invitations: organization ID, 25 per hour
  • OTP attempts: user or session ID, 5 per 10 minutes

Why transactions matter

Convex mutations are transactional. The counter update and scheduler entry commit together. If scheduling fails inside the mutation, the counter update rolls back. If two requests race for the final permit, Convex retries one against the latest bucket state.

The email action runs after the mutation commits and can still fail on its own. The transaction guarantees that an accepted permit creates a scheduled job, not that an external email provider succeeds.

The useful part is not the counter arithmetic. It is keeping the permit and the protected mutation work in one transaction.

When to use it

This approach fits mutation-based features where the backend can derive the subject. Never trust a user ID or IP address supplied by the client.

This limiter is useful for mutation-based application features. It is not authorization, idempotency, or DDoS protection, and reactive queries should not become mutations just to count reads. If you need sliding windows, richer retry information, or high-volume protection, use the official component or another dedicated layer.

Top