The Problem
Users sending messages too quickly can spam your application. You need to limit message frequency per user while allowing occasional bursts of legitimate activity.
Solution: Per-User Token Bucket
Use a per-user rate limit with a token bucket strategy. This allows steady messaging (10 per minute) while permitting short bursts when users haven’t been active.
Configuration
import { RateLimiter, MINUTE } from "@convex-dev/rate-limiter";
import { components } from "./_generated/api";
const rateLimiter = new RateLimiter(components.rateLimiter, {
// Allow 10 messages per minute per user
// Capacity of 3 allows bursts of 3 messages if tokens are available
sendMessage: {
kind: "token bucket",
rate: 10,
period: MINUTE,
capacity: 3,
},
});
export { rateLimiter };
How it works:
- Tokens refill at 10 per minute (one every ~6 seconds)
- Maximum 3 tokens can accumulate
- Each message consumes 1 token
- If user hasn’t sent messages recently, they can send 3 quickly
Implementation
Backend Mutation
import { v } from "convex/values";
import { mutation } from "./_generated/server";
import { rateLimiter } from "./rateLimits";
export const send = mutation({
args: {
text: v.string(),
channelId: v.id("channels"),
},
handler: async (ctx, args) => {
// Get authenticated user
const identity = await ctx.auth.getUserIdentity();
if (!identity) {
throw new Error("Not authenticated");
}
const userId = identity.subject;
// Check rate limit for this specific user
const { ok, retryAfter } = await rateLimiter.limit(
ctx,
"sendMessage",
{ key: userId }
);
if (!ok) {
throw new Error(
`Rate limit exceeded. Please wait ${Math.ceil(retryAfter! / 1000)} seconds.`
);
}
// Send the message
const messageId = await ctx.db.insert("messages", {
text: args.text,
channelId: args.channelId,
userId,
timestamp: Date.now(),
});
return { success: true, messageId };
},
});
Query to Check Rate Limit Status
import { query } from "./_generated/server";
export const getRateLimitStatus = query({
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return null;
return rateLimiter.check(ctx, "sendMessage", {
key: identity.subject,
});
},
});
Client-Side Integration
With React Hook
First, set up the hook API:
export const { getRateLimit, getServerTime } = rateLimiter.hookAPI(
"sendMessage",
{
key: async (ctx) => {
const user = await ctx.auth.getUserIdentity();
return user?.subject ?? "anonymous";
},
}
);
Then use it in your component:
import { useMutation } from "convex/react";
import { useRateLimit } from "@convex-dev/rate-limiter/react";
import { api } from "../convex/_generated/api";
import { useState, useEffect } from "react";
export function MessageInput({ channelId }: { channelId: string }) {
const sendMessage = useMutation(api.messages.send);
const [text, setText] = useState("");
const [error, setError] = useState("");
// Real-time rate limit status
const { status, check } = useRateLimit(
api.messages.getRateLimit,
{
getServerTimeMutation: api.messages.getServerTime,
count: 1,
}
);
const canSend = status.ok;
const waitSeconds = status.retryAt
? Math.ceil((status.retryAt - Date.now()) / 1000)
: 0;
const handleSend = async () => {
if (!canSend) {
setError(`Please wait ${waitSeconds} seconds`);
return;
}
setError("");
try {
await sendMessage({ text, channelId });
setText("");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to send");
}
};
return (
<div>
<input
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()}
placeholder="Type a message..."
disabled={!canSend}
/>
<button onClick={handleSend} disabled={!canSend}>
{canSend ? "Send" : `Wait ${waitSeconds}s`}
</button>
{error && <div className="error">{error}</div>}
</div>
);
}
Manual Error Handling
src/MessageInputSimple.tsx
import { useMutation } from "convex/react";
import { api } from "../convex/_generated/api";
import { useState } from "react";
export function MessageInputSimple({ channelId }: { channelId: string }) {
const sendMessage = useMutation(api.messages.send);
const [text, setText] = useState("");
const [error, setError] = useState("");
const handleSend = async () => {
try {
await sendMessage({ text, channelId });
setText("");
setError("");
} catch (err) {
// Extract wait time from error message
const message = err instanceof Error ? err.message : "Failed";
setError(message);
// Auto-clear error after wait time
if (message.includes("wait")) {
const match = message.match(/(\d+) seconds/);
if (match) {
setTimeout(() => setError(""), parseInt(match[1]) * 1000);
}
}
}
};
return (
<div>
<input
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSend()}
placeholder="Type a message..."
/>
<button onClick={handleSend}>Send</button>
{error && <div className="error">{error}</div>}
</div>
);
}
Testing the Rate Limit
import { internalMutation } from "./_generated/server";
import { rateLimiter } from "./rateLimits";
export const testMessageLimit = internalMutation({
handler: async (ctx) => {
const testUserId = "user123";
// Should be able to send 3 messages immediately (capacity)
for (let i = 0; i < 3; i++) {
const result = await rateLimiter.limit(ctx, "sendMessage", {
key: testUserId,
});
if (!result.ok) {
throw new Error(`Message ${i + 1} should have succeeded`);
}
}
// 4th message should be rate limited
const fourth = await rateLimiter.limit(ctx, "sendMessage", {
key: testUserId,
});
if (fourth.ok) {
throw new Error("4th message should be rate limited");
}
console.log(`✓ Rate limit working! Retry after ${fourth.retryAfter}ms`);
// Different user should have their own limit
const otherUser = await rateLimiter.limit(ctx, "sendMessage", {
key: "user456",
});
if (!otherUser.ok) {
throw new Error("Other user should not be rate limited");
}
console.log("✓ Per-user isolation working!");
},
});
Common Variations
// Only 5 messages per minute, no bursts
sendMessage: {
kind: "token bucket",
rate: 5,
period: MINUTE,
capacity: 1, // No burst capacity
}
// Allow bursts of 10 messages
sendMessage: {
kind: "token bucket",
rate: 10,
period: MINUTE,
capacity: 10,
}
const isPremium = await checkUserSubscription(ctx, userId);
const limitName = isPremium ? "sendMessagePremium" : "sendMessage";
const result = await rateLimiter.limit(ctx, limitName, {
key: userId,
});
Configure both:const rateLimiter = new RateLimiter(components.rateLimiter, {
sendMessage: { kind: "token bucket", rate: 10, period: MINUTE, capacity: 3 },
sendMessagePremium: { kind: "token bucket", rate: 100, period: MINUTE, capacity: 20 },
});
Capacity vs Rate: The capacity determines burst size, while rate controls sustained throughput. A capacity of 3 with rate of 10/minute means users can send 3 messages instantly, then must wait ~6 seconds between subsequent messages.
Use the React hook (useRateLimit) to show real-time feedback in your UI. This prevents users from hitting the rate limit and seeing errors.