StarterApp Docs
Security

Rate Limiting

Edge and application-layer rate limiting with Convex

Rate limiting protects applications from both attacks and accidents. Without limits, a single user could overwhelm the system, inflate costs, or block legitimate access.

Pro Tip

Two-layer strategy: Edge provider rate limiting protects HTTP endpoints, Convex durable limits protect business logic. Use assertLimitAction() in Convex actions for application-layer rate limiting.

Two-Layer Protection Strategy

The framework separates infrastructure protection from business rules. Edge providers handle DDoS attacks and HTTP flooding. Convex mutations enforce business rules like "10 support tickets per hour". This separation provides comprehensive protection without complexity.

Convex Rate Limiting

The framework provides a durable rate limiting system built on Convex. Every limit uses atomic counters with automatic cleanup.

Implementation

import { v } from "convex/values";
import { api } from "./_generated/api";
import { internalMutation, mutation } from "./_generated/server";
import { AppError } from "./_helpers/errors";

export const bump = mutation({
  args: {
    key: v.string(),
    bucket: v.number(),
    max: v.number(),
    ttlMs: v.optional(v.number()),
  },
  handler: async (ctx, { key, bucket, max, ttlMs }) => {
    const expiresAt = Date.now() + (ttlMs ?? 60_000);

    // Try to create new bucket row
    try {
      await ctx.db.insert("rateLimits", { key, bucket, count: 1, expiresAt });
      return { allowed: true, remaining: max - 1 };
    } catch {
      /* unique insert race, continue */
    }

    // Retry-safe patch loop
    for (let i = 0; i < 3; i++) {
      const doc = await ctx.db
        .query("rateLimits")
        .withIndex("by_key_bucket", (q) =>
          q.eq("key", key).eq("bucket", bucket)
        )
        .unique();

      if (doc) {
        const next = doc.count + 1;
        if (next > max) {
          return {
            allowed: false,
            retryAfterMs: Math.max(0, doc.expiresAt - Date.now()),
          };
        }
        try {
          await ctx.db.patch(doc._id, { count: next });
          return { allowed: true, remaining: max - next };
        } catch {
          /* write conflict */
        }
      }
    }
    return { allowed: false, retryAfterMs: ttlMs ?? 60_000 };
  },
});

export async function assertLimitAction(
  ctx: { runMutation: (mutation: any, args: any) => Promise<any> },
  options: {
    scope: string;
    viewerId: string;
    max?: number;
    windowMs?: number;
  }
) {
  const { scope, viewerId, max = 10, windowMs = 60_000 } = options;
  const bucket = Math.floor(Date.now() / windowMs);
  const rl = await ctx.runMutation(api.rateLimits.bump, {
    key: `${scope}:${viewerId}`,
    bucket,
    max,
    ttlMs: windowMs,
  });
  if (!rl.allowed) {
    throw new AppError("RATE_LIMITED", "Too many requests", {
      retryAfterMs: rl.retryAfterMs,
    });
  }
}
import { assertLimitAction } from "./rateLimits";
import { mutation } from "./_generated/server";
import { v } from "convex/values";

export const createTicket = mutation({
  args: { subject: v.string(), body: v.string() },
  handler: async (ctx, args) => {
    const userId = await requireAuth(ctx);

    // 5 support tickets per hour
    await assertLimitAction(ctx, {
      scope: "support-ticket",
      viewerId: userId,
      max: 5,
      windowMs: 3600_000,
    });

    const ticketId = await ctx.db.insert("supportTickets", {
      userId,
      subject: args.subject,
      body: args.body,
      createdAt: Date.now(),
    });

    return { ticketId };
  },
});
import { internalMutation } from "./_generated/server";

export const cleanupExpired = internalMutation({
  args: {},
  handler: async (ctx) => {
    const now = Date.now();
    const batch = await ctx.db
      .query("rateLimits")
      .withIndex("by_expiresAt", (q) => q.lt("expiresAt", now))
      .take(100);

    for (const entry of batch) {
      await ctx.db.delete(entry._id);
    }

    return { deleted: batch.length };
  },
});

Schedule in convex/crons.ts:

import { cronJobs } from "convex/server";
import { internal } from "./_generated/api";

const crons = cronJobs();
crons.interval("cleanup-rate-limits", { minutes: 5 }, internal.rateLimits.cleanupExpired);

export default crons;

How It Works

Bucket Calculation

The system divides time into discrete buckets. Each limit scope gets a separate counter per bucket:

const bucket = Math.floor(Date.now() / windowMs);
const key = `${scope}:${viewerId}`; // e.g., "support-ticket:user123"

A 60-second window creates a new bucket every 60 seconds. Requests within the same bucket share a counter.

Atomic Increment

The bump mutation attempts to create a new counter row. If it already exists, it increments atomically:

const doc = await ctx.db.query("rateLimits")
  .withIndex("by_key_bucket", (q) => q.eq("key", key).eq("bucket", bucket))
  .unique();

if (doc) {
  const next = doc.count + 1;
  if (next > max) {
    return { allowed: false, retryAfterMs: expiresAt - Date.now() };
  }
  await ctx.db.patch(doc._id, { count: next });
}

Retry Logic

Write conflicts can occur under high concurrency. The implementation retries up to 3 times:

for (let i = 0; i < 3; i++) {
  try {
    await ctx.db.patch(doc._id, { count: next });
    return { allowed: true, remaining: max - next };
  } catch {
    /* write conflict, retry */
  }
}

Error Handling

When the limit is exceeded, assertLimitAction throws an AppError with retry information:

if (!rl.allowed) {
  throw new AppError("RATE_LIMITED", "Too many requests", {
    retryAfterMs: rl.retryAfterMs,
  });
}

Scope Isolation

Different limit scopes are completely isolated. A user hitting the "support-ticket" limit can still perform "profile-update" operations. Choose descriptive scope names.

Common Patterns

// 3 password resets per day
await assertLimitAction(ctx, {
  scope: "password-reset",
  viewerId: email,
  max: 3,
  windowMs: 86_400_000, // 24 hours
});
// 100 searches per minute
await assertLimitAction(ctx, {
  scope: "search",
  viewerId: userId,
  max: 100,
  windowMs: 60_000,
});
const user = await getUser(ctx, userId);
const max = user.plan === "pro" ? 1000 : 100;

await assertLimitAction(ctx, {
  scope: "api-calls",
  viewerId: userId,
  max,
  windowMs: 3600_000, // 1 hour
});
// 10 anonymous requests per IP per minute
await assertLimitAction(ctx, {
  scope: "public-api",
  viewerId: clientIp,
  max: 10,
  windowMs: 60_000,
});

Edge Provider Limits

HTTP-level protection requires provider-specific configuration. Edge limits block requests before they reach your application code.

Vercel Configuration

{
  "functions": {
    "api/**/*.ts": {
      "rateLimit": {
        "duration": 60,
        "limit": 100
      }
    },
    "api/auth/**/*.ts": {
      "rateLimit": {
        "duration": 60,
        "limit": 10
      }
    }
  }
}
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, "10 s"),
});

export async function middleware(request: NextRequest) {
  const ip = request.ip ?? "127.0.0.1";
  const { success, limit, remaining } = await ratelimit.limit(ip);

  if (!success) {
    return new Response("Rate limit exceeded", { status: 429 });
  }

  // Continue with request
}

Cloudflare Configuration

Rule 1: Block IP if request count > 100 in 60s
Rule 2: Challenge IP if request count > 1000 in 3600s
Rule 3: Rate limit /api/auth/* to 10 req/min
export default {
  async fetch(request: Request, env: Env) {
    const ip = request.headers.get("CF-Connecting-IP");
    const key = `rate:${ip}`;
    const current = await env.KV.get(key);

    if (current && parseInt(current) > 100) {
      return new Response("Rate limited", { status: 429 });
    }

    await env.KV.put(key, (parseInt(current || "0") + 1).toString(), {
      expirationTtl: 60,
    });

    return fetch(request);
  },
};

Edge First

Configure edge rate limits before application limits. Edge protection is cheaper, faster, and blocks attacks before they consume resources.

Error Response Format

Rate limit errors follow consistent patterns:

// Thrown by assertLimitAction
{
  code: "RATE_LIMITED",
  message: "Too many requests",
  retryAfterMs: 45230
}

Frontend handling:

try {
  await api.support.createTicket({ subject, body });
} catch (err) {
  if (err.code === "RATE_LIMITED") {
    const seconds = Math.ceil(err.retryAfterMs / 1000);
    showError(`Rate limited. Retry in ${seconds}s`);
  }
}
import { secureErrorJson } from "@workspace/security";

export async function POST(request: Request) {
  try {
    await checkUploadRateLimit(userId);
  } catch (err) {
    if (err.code === "RATE_LIMITED") {
      return secureErrorJson(
        { message: "Too many uploads", code: "RATE_LIMITED" },
        { status: 429, headers: { "Retry-After": "60" } }
      );
    }
  }
}

Testing Rate Limits

Unit Test

import { assertLimitAction } from "../convex/rateLimits";

test("allows requests within limit", async () => {
  const ctx = mockConvexContext();

  for (let i = 0; i < 5; i++) {
    await expect(
      assertLimitAction(ctx, {
        scope: "test",
        viewerId: "user1",
        max: 5,
        windowMs: 60_000,
      })
    ).resolves.not.toThrow();
  }
});

test("blocks requests exceeding limit", async () => {
  const ctx = mockConvexContext();

  for (let i = 0; i < 5; i++) {
    await assertLimitAction(ctx, {
      scope: "test",
      viewerId: "user1",
      max: 5,
      windowMs: 60_000,
    });
  }

  await expect(
    assertLimitAction(ctx, {
      scope: "test",
      viewerId: "user1",
      max: 5,
      windowMs: 60_000,
    })
  ).rejects.toThrow("Too many requests");
});

Integration Test

test("API rate limiting", async () => {
  const user = await createTestUser();

  // First 10 requests succeed
  for (let i = 0; i < 10; i++) {
    const res = await fetch("/api/search", {
      headers: { Authorization: `Bearer ${user.token}` },
    });
    expect(res.status).toBe(200);
  }

  // 11th request is rate limited
  const res = await fetch("/api/search", {
    headers: { Authorization: `Bearer ${user.token}` },
  });
  expect(res.status).toBe(429);
});

Performance Considerations

Learn More