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
Edge Protection
Infrastructure-level HTTP rate limiting at Vercel/Cloudflare
Business Logic Limits
Durable per-user limits enforced in Convex mutations
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);
});