Loading skill
Install any skill in seconds. Free to start, no credit card required.
Get Started Free →Handle errors properly in Convex - throw for exceptional cases, return null for expected cases, provide clear error messages
| Test case | Without → With | Effect | Δ tokens | Δ turns |
|---|---|---|---|---|
| case-02 | ✗→✓ | ▲ Improved | 76% | 0% |
| case-05 | ✗→✓ | ▲ Improved | 53% | 0% |
| case-08 | ✗→✓ | ▲ Improved | 72% | 0% |
| case-09 | ✗→✓ | ▲ Improved | 42% | 0% |
| case-14 | ✗→✓ | ▲ Improved | 114% | 0% |
Proper error handling makes your app reliable and debuggable. Follow these patterns for Convex functions.
Throw when something unexpected happens or requirements aren't met:
typescriptexport const updateTask = mutation({ args: { taskId: v.id("tasks"), title: v.string() }, handler: async (ctx, args) => { // Authentication required (exceptional if missing) const identity = await ctx.auth.getUserIdentity(); if (!identity) { throw new Error("Not authenticated"); } const user = await getCurrentUser(ctx); const task = await ctx.db.get(args.taskId); // Task must exist if (!task) { throw new Error("Task not found"); } // User must own the task if (task.userId !== user._id) { throw new Error("Unauthorized: You don't own this task"); } await ctx.db.patch(args.taskId, { title: args.title }); }, });
Return null when absence is a normal, expected possibility:
typescriptexport const getTask = query({ args: { taskId: v.id("tasks") }, returns: v.union( v.object({ _id: v.id("tasks"), title: v.string(), }), v.null() ), handler: async (ctx, args): Promise<Doc<"tasks"> | null> => { // Task might not exist - that's expected return await ctx.db.get(args.taskId); }, }); // Client handles null gracefully const task = useQuery(api.tasks.getTask, { taskId }); if (!task) { return <div>Task not found</div>; }
typescript// Specific, actionable, user-friendly throw new Error("Email already registered"); throw new Error("Invalid file type. Only PNG and JPG allowed"); throw new Error("Task limit reached (10 per user)"); throw new Error("Unauthorized: Admin access required"); throw new Error("Invalid coupon code");
typescript// Vague, unhelpful throw new Error("Error"); throw new Error("Failed"); throw new Error("Invalid input"); throw new Error("DB error"); throw new Error("Something went wrong");
Create structured errors for better handling:
typescript// convex/lib/errors.ts export class UnauthorizedError extends Error { constructor(message = "Unauthorized") { super(message); this.name = "UnauthorizedError"; } } export class NotFoundError extends Error { constructor(resource: string) { super(`${resource} not found`); this.name = "NotFoundError"; } } export class ValidationError extends Error { constructor(field: string, issue: string) { super(`${field}: ${issue}`); this.name = "ValidationError"; } } // Usage export const deleteTask = mutation({ handler: async (ctx, args) => { const user = await getCurrentUser(ctx); const task = await ctx.db.get(args.taskId); if (!task) { throw new NotFoundError("Task"); } if (task.userId !== user._id) { throw new UnauthorizedError("You don't own this task"); } await ctx.db.delete(args.taskId); }, });
typescript// ErrorBoundary.tsx import { Component, ReactNode } from "react"; class ConvexErrorBoundary extends Component< { children: ReactNode }, { hasError: boolean; error?: Error } > { state = { hasError: false, error: undefined }; static getDerivedStateFromError(error: Error) { return { hasError: true, error }; } render() { if (this.state.hasError) { return ( <div> <h2>Something went wrong</h2> <p>{this.state.error?.message}</p> </div> ); } return this.props.children; } } // Usage <ConvexErrorBoundary> <TaskList /> </ConvexErrorBoundary>
typescriptconst createTask = useMutation(api.tasks.create); const handleCreate = async () => { try { await createTask({ title: "New task" }); toast.success("Task created!"); } catch (error) { if (error instanceof Error) { toast.error(error.message); } else { toast.error("Failed to create task"); } } };
typescript"use node"; import { action } from "./_generated/server"; import * as Sentry from "@sentry/node"; export const logError = action({ args: { error: v.string(), context: v.optional(v.any()), }, handler: async (ctx, args) => { // Log to Sentry Sentry.captureException(new Error(args.error), { extra: args.context, }); // Also log to Convex logs console.error("Application error:", args.error, args.context); }, });
typescriptexport const processPayment = mutation({ handler: async (ctx, args) => { try { const result = await processStripePayment(args); return result; } catch (error) { // Log error for debugging console.error("Payment processing failed:", { error: error instanceof Error ? error.message : "Unknown error", userId: ctx.user._id, amount: args.amount, }); // Re-throw with user-friendly message throw new Error("Payment failed. Please try again."); } }, });
Return structured validation errors:
typescriptexport const createUser = mutation({ args: { email: v.string(), age: v.number(), }, handler: async (ctx, args) => { // Validate email if (!args.email.includes("@")) { throw new Error("Invalid email address"); } // Validate age if (args.age < 18) { throw new Error("Must be 18 or older"); } // Check if email already exists const existing = await ctx.db .query("users") .withIndex("by_email", q => q.eq("email", args.email)) .unique(); if (existing) { throw new Error("Email already registered"); } return await ctx.db.insert("users", args); }, });
Actions can fail in different ways:
typescript"use node"; export const sendEmail = action({ args: { to: v.string(), subject: v.string() }, handler: async (ctx, args) => { try { const response = await fetch("https://api.sendgrid.com/v3/mail/send", { method: "POST", headers: { Authorization: `Bearer ${process.env.SENDGRID_KEY}`, "Content-Type": "application/json", }, body: JSON.stringify({ to: args.to, from: "noreply@example.com", subject: args.subject, }), }); if (!response.ok) { const error = await response.text(); console.error("SendGrid error:", error); throw new Error("Failed to send email"); } return { success: true }; } catch (error) { // External API errors console.error("Email sending failed:", error); // Don't expose internal details to client throw new Error("Unable to send email. Please try again later."); } }, });
For transient failures:
typescriptasync function withRetry<T>( fn: () => Promise<T>, maxAttempts = 3, delayMs = 1000 ): Promise<T> { let lastError: Error; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { return await fn(); } catch (error) { lastError = error as Error; console.warn(`Attempt ${attempt} failed:`, lastError.message); if (attempt < maxAttempts) { await new Promise(resolve => setTimeout(resolve, delayMs * attempt)); } } } throw lastError!; } // Usage in action export const fetchExternalData = action({ handler: async (ctx) => { return await withRetry(async () => { const response = await fetch("https://api.example.com/data"); if (!response.ok) throw new Error("API request failed"); return await response.json(); }); }, });
Provide recovery options:
typescriptexport const updateProfile = mutation({ args: { name: v.string(), bio: v.optional(v.string()), }, handler: async (ctx, args) => { const user = await getCurrentUser(ctx); // Validate name length if (args.name.length < 2) { throw new Error("Name must be at least 2 characters"); } if (args.name.length > 100) { throw new Error("Name must be less than 100 characters"); } // Check for profanity (example) if (containsProfanity(args.name)) { throw new Error("Name contains inappropriate content"); } await ctx.db.patch(user._id, { name: args.name, bio: args.bio, }); }, }); // Client can retry with different name const handleUpdate = async (name: string) => { try { await updateProfile({ name }); } catch (error) { // Show error, let user fix and retry setError(error.message); // Form stays populated for retry } };
typescriptexport const complexOperation = mutation({ handler: async (ctx, args) => { console.log("Starting operation", { userId: ctx.user._id, args }); const step1 = await doStep1(ctx); console.log("Step 1 complete", step1); const step2 = await doStep2(ctx, step1); console.log("Step 2 complete", step2); return step2; }, }); // View logs in Convex Dashboard
typescriptexport const processOrder = mutation({ handler: async (ctx, args) => { try { const order = await createOrder(ctx, args); await processPayment(ctx, order); await sendConfirmation(ctx, order); return order; } catch (error) { // Include context in error console.error("Order processing failed", { error: error instanceof Error ? error.message : "Unknown", orderId: order?._id, userId: ctx.user._id, step: "payment", // Which step failed }); throw error; } }, });
Other measured skills in the registry, with their headline benchmark lift.