Skip to main content

Overview

The Convex Rate Limiter provides two approaches to handling rate limit violations:
  1. Manual handling: Check the ok property and handle errors yourself
  2. Automatic errors: Use throws: true to automatically throw errors

Manual Error Handling (Default)

By default, limit() returns { ok, retryAfter } and never throws:
const { ok, retryAfter } = await rateLimiter.limit(ctx, "sendMessage", {
  key: userId,
});

if (!ok) {
  return { 
    error: `Rate limit exceeded. Try again in ${retryAfter}ms`,
    retryAfter,
  };
}

// Continue with the operation
This approach gives you full control over the error response.

Automatic Error Throwing

Use throws: true to automatically throw a ConvexError when the rate limit is exceeded:
await rateLimiter.limit(ctx, "sendMessage", {
  key: userId,
  throws: true,
});

// If we reach here, the rate limit was not exceeded
From the README:
“It throws a ConvexError with RateLimitError data (data: {kind, name, retryAfter}) instead of returning when ok is false.”

Real Example from Source Code

From example/convex/example.ts:
export const test = internalMutation({
  args: {},
  handler: async (ctx) => {
    // First request succeeds with throws: true
    const first = await rateLimiter.limit(ctx, "sendMessage", {
      key: "user1",
      throws: true,
    });
    assert(first.ok);
    assert(!first.retryAfter);
    
    // Second request succeeds
    const second = await rateLimiter.limit(ctx, "sendMessage", {
      key: "user1",
    });
    assert(second.ok);
    
    // Third request succeeds
    await rateLimiter.limit(ctx, "sendMessage", {
      key: "user1",
      throws: true,
    });
    
    let threw = false;
    // Fourth request should throw (capacity exceeded)
    try {
      await rateLimiter.limit(ctx, "sendMessage", {
        key: "user1",
        throws: true,
      });
    } catch (e) {
      threw = true;
      assert(isRateLimitError(e));
    }
    assert(threw);
  },
});

The RateLimitError Type

When throws: true is used, the error data follows this structure:
type RateLimitError = {
  kind: "RateLimited";
  name: string;        // The rate limit name
  retryAfter: number;  // Milliseconds until retry could succeed
};

Using isRateLimitError Helper

The library provides a type guard to check if an error is a rate limit error:
import { isRateLimitError } from "@convex-dev/rate-limiter";

try {
  await rateLimiter.limit(ctx, "sendMessage", { 
    key: userId, 
    throws: true 
  });
  
  // Process the message
  await ctx.db.insert("messages", { ... });
  
} catch (error) {
  if (isRateLimitError(error)) {
    // Handle rate limit error specifically
    return { 
      error: "Too many messages",
      retryAfter: error.data.retryAfter,
      limitName: error.data.name,
    };
  }
  // Handle other errors
  throw error;
}

Implementation

From src/client/index.ts:35-42:
export function isRateLimitError(
  error: unknown,
): error is { data: RateLimitError } {
  return (
    error instanceof ConvexError &&
    (error as any).data["kind"] === "RateLimited"
  );
}

ConvexError Integration

Rate limit errors are thrown as ConvexError instances, which means they:
  • Are automatically serialized and sent to the client
  • Include structured data that clients can parse
  • Work seamlessly with Convex’s error handling
import { ConvexError } from "convex/values";

try {
  await rateLimiter.limit(ctx, "apiCall", { throws: true });
} catch (error) {
  if (error instanceof ConvexError && error.data.kind === "RateLimited") {
    console.log(`Rate limited: ${error.data.name}`);
    console.log(`Retry after: ${error.data.retryAfter}ms`);
  }
}

Client-Side Error Handling

When rate limit errors reach the client, you can handle them in your React components:
import { useMutation } from "convex/react";
import { api } from "../convex/_generated/api";
import { isRateLimitError } from "@convex-dev/rate-limiter";

function ChatBox() {
  const sendMessage = useMutation(api.messages.send);
  const [error, setError] = useState<string | null>(null);
  
  const handleSend = async (text: string) => {
    try {
      await sendMessage({ text });
      setError(null);
    } catch (err) {
      if (isRateLimitError(err)) {
        const seconds = Math.ceil(err.data.retryAfter / 1000);
        setError(`Too many messages. Wait ${seconds}s before trying again.`);
      } else {
        setError("Failed to send message");
      }
    }
  };
  
  return (
    <div>
      {error && <div className="error">{error}</div>}
      {/* ... */}
    </div>
  );
}

Choosing the Right Approach

Use automatic errors when:
  • Rate limiting is a hard requirement (security, abuse prevention)
  • You want concise code without explicit checks
  • The operation should always fail when rate limited
  • You’re protecting against abuse (failed logins, spam)
// Concise and clear: this MUST be rate limited
await rateLimiter.limit(ctx, "failedLogins", { 
  key: email,
  throws: true,
});

Combining Multiple Rate Limits

You can combine multiple rate limits with different error handling strategies:
export const sendMessage = mutation({
  args: { text: v.string() },
  handler: async (ctx, args) => {
    const userId = await getUserId(ctx);
    
    // Strict: Per-user message rate (throw on violation)
    await rateLimiter.limit(ctx, "userMessages", {
      key: userId,
      throws: true,
    });
    
    // Lenient: Global spam prevention (warn on violation)
    const globalCheck = await rateLimiter.limit(ctx, "globalMessages");
    if (!globalCheck.ok) {
      console.warn("Global rate limit approaching capacity");
      // Continue anyway
    }
    
    await ctx.db.insert("messages", { text: args.text, userId });
  },
});

Best Practices

Even with throws: true, ensure your client code handles the error:
try {
  await mutation({ ... });
} catch (err) {
  if (isRateLimitError(err)) {
    // Show user-friendly message
  }
}
Convert milliseconds to user-friendly units:
const seconds = Math.ceil(retryAfter / 1000);
const minutes = Math.ceil(retryAfter / 60000);

if (retryAfter < 60000) {
  return `Try again in ${seconds} seconds`;
} else {
  return `Try again in ${minutes} minutes`;
}
Monitor rate limit hits to detect abuse or adjust limits:
const { ok, retryAfter } = await rateLimiter.limit(ctx, "action", { key });

if (!ok) {
  console.warn(`Rate limit hit: ${key}, retry after ${retryAfter}ms`);
  // Consider logging to analytics
}

Next Steps

Build docs developers (and LLMs) love