| name | convex-development |
| description | Apply Convex database best practices including cost mitigation, bandwidth optimization, embeddings/vector search patterns, security, query optimization, schema migrations, and architectural patterns. Use when building Convex backends, optimizing performance, handling embeddings, reviewing Convex code, or discussing cost-effective Convex architecture. |
Convex Development
Best practices for building robust, secure, performant, cost-effective Convex backends. Updated November 2025 with latest patterns for bandwidth optimization and embeddings management.
Core Principle
Deep modules via the convex/model/ pattern.
Most logic should be plain TypeScript functions; query/mutation/action wrappers should be thin.
convex/
_generated/
model/ # Core business logic (testable, reusable)
users.ts
teams.ts
users.ts # Public API (thin wrappers)
teams.ts
schema.ts
Benefits:
- Easier testing (pure functions)
- Better code reuse
- Simpler refactoring
- Clear separation of concerns
- Type-safe helpers callable from any function
Example:
// convex/model/users.ts - Business logic
import { QueryCtx } from "../_generated/server";
export async function getCurrentUser(ctx: QueryCtx) {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthenticated");
return await ctx.db
.query("users")
.withIndex("by_token", (q) => q.eq("tokenIdentifier", identity.tokenIdentifier))
.unique();
}
// convex/users.ts - Public API (thin wrapper)
import { query } from "./_generated/server";
import * as Users from "./model/users";
export const getCurrentUser = query({
args: {},
handler: (ctx) => Users.getCurrentUser(ctx),
});
1. Cost Mitigation & Bandwidth Optimization
Bandwidth is often the largest and most unpredictable cost. Costs driven by function execution time, storage, and database bandwidth.
Golden Rule: "Index What You Query"
Queries without .withIndex() perform full table scans — the single largest contributor to excess bandwidth usage.
❌ Anti-pattern: Full table scan
// Scans ENTIRE table, massive bandwidth waste
const tomsMessages = await ctx.db
.query("messages")
.filter((q) => q.eq(q.field("author"), "Tom"))
.collect();
✅ Best practice: Indexed query
defineTable({
author: v.string(),
}).index("by_author", ["author"]);
const tomsMessages = await ctx.db
.query("messages")
.withIndex("by_author", (q) => q.eq("author", "Tom"))
.collect();
Never Fetch Unbounded Collections
A query returning 1,000+ documents consumes significant bandwidth and causes UI jank. Always use .paginate().
❌ Anti-pattern: Unbounded collect
const allMovies = await ctx.db.query("movies").collect();
const spielbergMovies = allMovies.filter(m => m.director === "Steven Spielberg");
✅ Best practice: Pagination with index
import { paginationOptsValidator } from "convex/server";
export const listMessages = query({
args: {
channelId: v.id("channels"),
paginationOpts: paginationOptsValidator,
},
handler: async (ctx, { channelId, paginationOpts }) => {
return await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", channelId))
.order("desc")
.paginate(paginationOpts);
},
});
// Client usage
const { results, status, loadMore } = usePaginatedQuery(
api.messages.listMessages,
{ channelId }
);
Separate Hot and Cold Data
When a document has frequently-updated fields (e.g., view counter) mixed with static fields (e.g., blog post body), the entire document is re-sent to subscribed clients on every update. Split into separate tables.
❌ Anti-pattern: Mixed update frequency
defineTable({
title: v.string(),
body: v.string(),
viewCount: v.number(), // Updates frequently, causes entire doc to resend
});
✅ Best practice: Separated by update frequency
// Posts table (rarely changes)
defineTable({
title: v.string(),
body: v.string(),
});
// Post stats table (frequently updated)
defineTable({
postId: v.id("posts"),
viewCount: v.number(),
lastViewed: v.number(),
}).index("by_post", ["postId"]);
Batch Mutations
Instead of many small mutations from the client, create a single mutation accepting an array. Reduces network overhead and function execution costs.
❌ Anti-pattern: Loop of mutations
// Client code
for (const item of items) {
await api.items.create(item);
}
✅ Best practice: Batch mutation
export const batchCreateItems = mutation({
args: { items: v.array(v.object({ name: v.string(), ... })) },
handler: async (ctx, { items }) => {
for (const item of items) {
await ctx.db.insert("items", item);
}
},
});
Monitor Functions Log
Use the Functions Log in Convex Dashboard to identify slow queries. Add indexes for any query taking >50ms.
2. Embeddings & Vector Search
Vector search is a core Convex feature. How you store embeddings critically impacts performance and cost.
Default Pattern: Co-location (Same Table)
For most applications, store embedding vector in the same table as source data. Simplest and most effective.
✅ Best practice: Co-located embeddings
// convex/schema.ts
defineTable({
text: v.string(),
embedding: v.array(v.float64()), // OpenAI embeddings are 1536 dims
})
.vectorIndex("by_embedding", {
vectorField: "embedding",
dimensions: 1536,
filterFields: [], // Add fields here to filter during vector search
});
Advanced Pattern: Separate Embeddings Table
Only use separate table when:
- Multiple embeddings per document (e.g., different models or chunked text)
- Source document is extremely large (keep main table lean)
- Embeddings generated by decoupled process
This pattern requires "joins" (two ctx.db.get() calls) or denormalization, adding complexity.
❌ Avoid unless necessary: Separate tables
// Main documents table
defineTable({
title: v.string(),
content: v.string(),
});
// Separate embeddings table
defineTable({
documentId: v.id("documents"),
embedding: v.array(v.float64()),
})
.index("by_document", ["documentId"])
.vectorIndex("by_embedding", {
vectorField: "embedding",
dimensions: 1536,
});
Vector Search Must Use Actions
Vector searches must be performed inside actions using ctx.vectorSearch.
✅ Best practice: Vector search in action
// convex/similarItems.ts
import { action } from "./_generated/server";
import { api, internal } from "./_generated/api";
export const findSimilar = action({
args: { id: v.id("documents") },
handler: async (ctx, args) => {
const doc = await ctx.runQuery(api.documents.get, { id: args.id });
if (!doc || !doc.embedding) {
throw new Error("Document or embedding not found");
}
const results = await ctx.vectorSearch("documents", "by_embedding", {
vector: doc.embedding,
limit: 10,
});
return results;
},
});
Batch Embeddings API Calls
Fetch multiple embeddings at once to reduce overhead.
❌ Anti-pattern: Sequential API calls
for (const doc of docs) {
const embedding = await openai.embeddings.create({
input: doc.text,
model: "text-embedding-ada-002",
});
await ctx.db.patch(doc._id, { embedding: embedding.data[0].embedding });
}
✅ Best practice: Batch embeddings
// Batch all texts together
const embeddings = await openai.embeddings.create({
input: docs.map(d => d.text),
model: "text-embedding-ada-002",
});
// Update all documents
for (let i = 0; i < docs.length; i++) {
await ctx.db.patch(docs[i]._id, {
embedding: embeddings.data[i].embedding
});
}
Model Consistency
Different embedding models produce incompatible arrays. Commit to a single model (e.g., text-embedding-ada-002) rather than experimenting across providers.
When to Separate Embeddings (Critical Decision)
Keep embeddings WITH source data when:
- ✅ One embedding per document
- ✅ Source document is small-to-medium size (<10KB)
- ✅ Embeddings generated inline with document creation
- ✅ Embeddings are always needed with the document
Separate embeddings ONLY when:
- ⚠️ Multiple embeddings per document (chunks, different models)
- ⚠️ Source document is very large (>100KB)
- ⚠️ Embeddings generated asynchronously/decoupled
- ⚠️ Bandwidth optimization: Embeddings 1536 floats (~6KB each) — if frequently fetching documents WITHOUT needing embeddings, separation reduces bandwidth
3. Query & Performance Patterns
Compound Indexes for Multi-Field Queries
Order matters. Match the order of your eq filters for maximum efficiency.
✅ Best practice: Ordered compound index
defineTable({
status: v.string(),
priority: v.string(),
dueDate: v.number(),
})
.index("by_status_and_priority", ["status", "priority"])
.index("by_status_and_due", ["status", "dueDate"]);
// Query matches index order
const tasks = await ctx.db
.query("tasks")
.withIndex("by_status_and_priority", (q) =>
q.eq("status", "active").eq("priority", "high")
)
.collect();
Indexes Define Sort Order
An index also defines sort order. To sort by dueDate, you need an index on that field.
defineTable({
channelId: v.id("channels"),
_creationTime: v.number(),
})
.index("by_channel", ["channelId"]) // Sorted by _creationTime (default)
.index("by_channel_reverse", ["channelId", "_creationTime"]); // Explicit sort field
// Ordered query
const messages = await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", channelId))
.order("desc")
.take(50);
Staged Index Builds
When adding an index to a very large table, backfill can be slow. Deploy index in "staged" state, wait for backfill in dashboard, then deploy "enabled" state to avoid blocking.
Query Segmentation for Better Caching
Separate queries by update frequency to optimize reactivity.
❌ Anti-pattern: One query touches all data
export const getUser = query({
args: { userId: v.id("users") },
handler: async (ctx, { userId }) => {
const user = await ctx.db.get(userId);
const preferences = await ctx.db.query("preferences")...collect();
const notifications = await ctx.db.query("notifications")...collect();
return { user, preferences, notifications }; // Entire result invalidates if ANY part changes
},
});
✅ Best practice: Separate queries
export const getUser = query({
args: { userId: v.id("users") },
handler: (ctx, { userId }) => ctx.db.get(userId),
});
export const getUserPreferences = query({
args: { userId: v.id("users") },
handler: async (ctx, { userId }) => {
return await ctx.db.query("preferences")
.withIndex("by_userId", (q) => q.eq("userId", userId))
.first();
},
});
export const getUnreadNotifications = query({
args: { userId: v.id("users") },
handler: async (ctx, { userId }) => {
return await ctx.db.query("notifications")
.withIndex("by_userId_unread", (q) => q.eq("userId", userId).eq("unread", true))
.collect();
},
});
Use .unique() vs .first() Appropriately
.unique(): Enforces exactly one result (throws if multiple)
.first(): Returns first result or null
// ✅ Use .unique() when expecting exactly one
const user = await ctx.db
.query("users")
.withIndex("by_email", (q) => q.eq("email", email))
.unique(); // Throws if multiple
// ✅ Use .first() when getting most recent
const latestMessage = await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", channelId))
.order("desc")
.first();
Minimize ctx.runQuery/runMutation Calls from Actions
Each call is a separate transaction; risk of inconsistency between calls.
❌ Anti-pattern: Multiple sequential transactions
export const processOrder = action({
handler: async (ctx, { orderId }) => {
const order = await ctx.runQuery(api.orders.get, { orderId });
const user = await ctx.runQuery(api.users.get, { userId: order.userId });
// ❌ Order might have changed between these calls!
await ctx.runMutation(api.orders.update, { orderId, status: "processing" });
},
});
✅ Best practice: Single transaction
export const processOrder = action({
handler: async (ctx, { orderId }) => {
const { order, user } = await ctx.runQuery(
internal.orders.getOrderWithUser,
{ orderId }
);
await ctx.runMutation(internal.orders.update, { orderId, status: "processing" });
},
});
export const getOrderWithUser = internalQuery({
handler: async (ctx, { orderId }) => {
const order = await ctx.db.get(orderId);
if (!order) throw new Error("Order not found");
const user = await ctx.db.get(order.userId);
return { order, user };
},
});
Index Design Best Practices
Create indexes for all query patterns:
defineTable({
channelId: v.id("channels"),
userId: v.id("users"),
content: v.string(),
_creationTime: v.number(),
})
.index("by_channel", ["channelId"]) // For: list all messages in channel
.index("by_user", ["userId"]) // For: list user's messages
.index("by_channel_and_user", ["channelId", "userId"]); // For: user's messages in channel
Avoid redundant indexes: by_foo and by_foo_and_bar are usually redundant (keep compound index).
Name indexes clearly: by_userId_createdAt not idx1.
4. Security & Access Control
Critical Rule: Trust ctx.auth, Never User Input
Authentication data from users is spoofable. Always use ctx.auth.getUserIdentity().
❌ Anti-pattern: Spoofable auth
export const deleteMessage = mutation({
args: { messageId: v.id("messages"), userEmail: v.string() },
handler: async (ctx, { messageId, userEmail }) => {
const message = await ctx.db.get(messageId);
if (message.authorEmail === userEmail) { // ❌ Email can be faked!
await ctx.db.delete(messageId);
}
},
});
✅ Best practice: Use ctx.auth
export const deleteMessage = mutation({
args: { messageId: v.id("messages") },
handler: async (ctx, { messageId }) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthenticated");
const message = await ctx.db.get(messageId);
if (!message) throw new Error("Message not found");
if (message.authorTokenIdentifier !== identity.tokenIdentifier) {
throw new Error("Unauthorized");
}
await ctx.db.delete(messageId);
},
});
Row-Level Security (RLS) Pattern
Most common pattern: users can only access their own data. Add userId field, create index.
✅ Best practice: RLS with userId
// Schema
defineTable({
userId: v.string(),
content: v.string(),
}).index("by_user", ["userId"]);
// Query
export const getMyDocuments = query({
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return [];
return await ctx.db
.query("documents")
.withIndex("by_user", (q) => q.eq("userId", identity.subject))
.collect();
},
});
Centralize Authorization with convex-helpers
Use convex-helpers package for reusable RLS/RBAC patterns.
✅ Best practice: Custom functions for auth middleware
// convex/functions.ts
import { customMutation } from "convex-helpers/server/customFunctions";
import { mutation } from "./_generated/server";
// Authenticated mutation builder
export const userMutation = customMutation(mutation, {
args: {},
input: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthenticated");
const user = await ctx.db
.query("users")
.withIndex("by_token", (q) => q.eq("tokenIdentifier", identity.tokenIdentifier))
.unique();
if (!user) throw new Error("User not found");
return { ctx: { user }, args: {} };
},
});
// Usage: ctx.user is now type-safe and guaranteed to exist
export const createNote = userMutation({
args: { content: v.string() },
handler: async (ctx, { content }) => {
await ctx.db.insert("notes", {
content,
authorId: ctx.user._id, // ✅ Type-safe!
});
},
});
Parametrized Custom Functions (RBAC)
// convex/functions.ts
type Role = "admin" | "user" | "anonymous";
export const teamMutation = customMutation(mutation, {
args: { teamId: v.id("teams") },
input: async (ctx, args, opts: { role: Role }) => {
const user = await getCurrentUser(ctx);
await ensureUserHasRoleOnTeam(ctx, user, args.teamId, opts.role);
return { ctx: { user, teamId: args.teamId }, args: {} };
},
});
// Usage: role is enforced at definition time
export const suspendUser = teamMutation({
role: "admin", // ✅ Type error if missing!
args: { targetUserId: v.id("users") },
handler: async (ctx, { targetUserId }) => {
// Only admins can reach here
await ctx.db.patch(membershipId, { status: "suspended" });
},
});
Argument Validators for All Public Functions
Public functions are exposed to the internet. Validate all arguments.
❌ No validation - vulnerable
export const updateMessage = mutation({
handler: async (ctx, { id, update }) => {
await ctx.db.patch(id, update); // Can update ANY document, ANY field!
},
});
✅ Strict validation
import { v } from "convex/values";
export const updateMessage = mutation({
args: {
id: v.id("messages"),
update: v.object({
body: v.optional(v.string()),
author: v.optional(v.string()),
}),
},
handler: async (ctx, { id, update }) => {
await ctx.db.patch(id, update);
},
});
Only Schedule and ctx.run* Internal Functions
Public functions are exposed to attackers. Use internal.* for scheduling and ctx.run* calls.
❌ Scheduling public function
import { crons } from "./_generated/server";
import { api } from "./_generated/api";
crons.daily(
"send reminder",
{ hourUTC: 17, minuteUTC: 30 },
api.messages.sendMessage, // ❌ Public function - anyone can call!
{ author: "System", body: "Daily reminder" }
);
✅ Schedule internal function
import { internal } from "./_generated/api";
export const sendMessage = mutation({
args: { body: v.string() },
handler: async (ctx, { body }) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error("Unauthorized");
await sendMessageHelper(ctx, { body, author: identity.name ?? "Anonymous" });
},
});
export const sendInternalMessage = internalMutation({
args: { body: v.string(), author: v.string() },
handler: async (ctx, { body, author }) => {
await sendMessageHelper(ctx, { body, author });
},
});
crons.daily(
"send reminder",
{ hourUTC: 17, minuteUTC: 30 },
internal.messages.sendInternalMessage, // ✅ Internal function
{ author: "System", body: "Daily reminder" }
);
5. Schema Design & Migrations
Convex Auto-Migrates (No Manual Migrations)
Schema changes are immediate in dev, require push in prod. No SQL migration files needed.
The "Expand and Contract" Pattern
Use for any breaking schema change (renaming, type changes, removing fields).
Four Phases: Expand → Migrate → Contract → Cleanup
Example: Renaming name to fullName
Phase 1: Expand — Add new field as optional, dual write
// schema.ts - Step 1
defineTable({
name: v.string(),
fullName: v.optional(v.string()), // NEW - optional
});
// mutation.ts - Step 1
export const createUser = internalMutation({
args: { name: v.string() },
handler: async (ctx, args) => {
await ctx.db.insert("users", {
name: args.name,
fullName: args.name, // Write to both
});
},
});
Phase 2: Migrate — Backfill existing records
// convex/migrations.ts
export const backfillFullNames = internalAction({
handler: async (ctx) => {
const users = await ctx.runQuery(internal.users.listAll);
for (const user of users) {
if (!user.fullName) {
await ctx.runMutation(internal.users.patch, {
userId: user._id,
fullName: user.name,
});
}
}
},
});
// For large datasets, use Convex Migration Component
Phase 3: Contract — Switch reads to new field, stop writing to old field
// Read from fullName only
export const getUser = query({
handler: async (ctx, { userId }) => {
const user = await ctx.db.get(userId);
return { name: user.fullName }; // Read from new field
},
});
// Stop dual-writing (remove name from inserts)
Phase 4: Cleanup — Remove old field from schema
// schema.ts - Final
defineTable({
fullName: v.string(), // Only new field remains
});
// May need migration to unset old field if Convex blocks removal
Migration Best Practices
✅ Safe patterns:
- Add optional fields first
- Backfill via internal actions
- Test in dev/preview before prod
- Use
v.optional()for backward compatibility - Use Convex Migration Component for large datasets (>10k docs)
❌ Dangerous patterns:
- Removing fields before code updated
- Changing field types without migration
- Breaking changes without deprecation period
- Testing migrations directly in production
- No rollback strategy
Staged Rollout for Large Tables
When backfilling large tables (>100k documents), use staged approach:
- Deploy schema with optional field
- Run backfill migration in background (may take hours)
- Monitor completion in dashboard
- Deploy code changes to read from new field
- Remove old field after grace period
6. Architectural Patterns
File Organization
convex/
_generated/
model/ # Business logic (reusable, testable)
users.ts
teams.ts
messages.ts
users.ts # Public API (thin wrappers)
teams.ts
messages.ts
schema.ts # Database schema
http.ts # HTTP endpoints
crons.ts # Scheduled functions
auth.ts # Authentication config
Transactional State Machines
Model entity status explicitly. Use mutations to perform atomic state transitions.
✅ Best practice: State machine pattern
// Schema
defineTable({
orderId: v.string(),
status: v.union(
v.literal("pending"),
v.literal("processing"),
v.literal("shipped"),
v.literal("delivered"),
v.literal("cancelled")
),
});
// Mutation with state validation
export const shipOrder = mutation({
args: { orderId: v.id("orders") },
handler: async (ctx, { orderId }) => {
const order = await ctx.db.get(orderId);
if (!order) throw new Error("Order not found");
// Validate state transition
if (order.status !== "processing") {
throw new Error(`Cannot ship order in ${order.status} state`);
}
await ctx.db.patch(orderId, { status: "shipped" });
},
});
CQRS-like Separation
Use queries for real-time reactive reads. Use httpAction for write-heavy, non-reactive endpoints (webhooks).
✅ Best practice: Separate read/write paths
// Query: Real-time reactive reads
export const getMessages = query({
handler: async (ctx) => {
return await ctx.db.query("messages").collect();
},
});
// httpAction: Webhook (no reactivity needed)
export const receiveWebhook = httpAction(async (ctx, request) => {
const payload = await request.json();
await ctx.runMutation(internal.webhooks.processPayload, { payload });
return new Response("OK", { status: 200 });
});
Optimistic Updates with Server-Generated IDs
Generate temporary client-side ID for optimistic update, then patch with real server ID.
✅ Best practice: Optimistic with ID reconciliation
// Client code (React)
const optimisticId = useOptimistic();
const handleCreate = async () => {
// Optimistic update
setMessages([...messages, { _id: optimisticId, text: "New message", pending: true }]);
// Server call
const realId = await createMessage({ text: "New message" });
// Replace optimistic with real
setMessages(messages.map(m => m._id === optimisticId ? { ...m, _id: realId, pending: false } : m));
};
Function Type Selection
When to use:
- Query: Read-only, reactive, cached (UI updates automatically)
- Mutation: Write data, atomic transactions
- Action: External API calls, non-deterministic operations (time, random, fetch), vector search
- Internal: Scheduled functions,
ctx.run*calls, administrative tasks
Example:
// Query: Read data reactively
export const listMessages = query({...});
// Mutation: Write to database
export const createMessage = mutation({...});
// Action: Call external API or vector search
export const sendEmail = action({...});
export const searchSimilar = action({...});
// Internal: Scheduled or admin tasks
export const cleanupOldData = internalMutation({...});
Transaction Boundaries
Mutations are atomic. All database operations succeed or all fail.
export const transferCredits = mutation({
handler: async (ctx, { fromUserId, toUserId, amount }) => {
// Atomic: both updates succeed or both fail
await ctx.db.patch(fromUserId, { credits: (user.credits - amount) });
await ctx.db.patch(toUserId, { credits: (user.credits + amount) });
},
});
Naming Conventions
❌ Avoid generic names:
Manager,Util,Helper→ Vague, non-specificService,Handler,Processor→ Sometimes justified, often lazy
✅ Use intention-revealing names:
listMessagesnotgetDatacreateTeamnotdoStuffsendWelcomeEmailnothelpergetTeamBySlugnotfind
7. Anti-Patterns Scanner
Quick checks when reviewing Convex code:
Performance Anti-Patterns
❌ Unbounded .collect() calls
- Use pagination for large result sets
- Use
.take(n)for bounded results
❌ Using .filter() on database queries
- Use
.withIndex()for efficient filtering
❌ Missing indexes for common queries
- Create indexes for all query patterns
- Check index usage in dashboard
❌ Query mixing update frequencies
- Separate queries by update frequency for better caching
❌ Not awaiting promises
- Enable
no-floating-promisesESLint rule - Unwaited promises cause silent failures
Modern Anti-Patterns (2025)
❌ Ignoring Reactivity
- Fetching data in
useEffectwith one-off action call instead ofuseQuery - Forfeits Convex's greatest strength: real-time updates
❌ Fat Client Logic
- Putting authorization logic, complex calculations, or multi-step workflows on client
- All business logic belongs in mutations and actions
❌ Storing Large Files/Blobs in Documents
- Do not store user-uploaded images, videos, or large JSON blobs in documents
- Bloats documents, consumes massive bandwidth
- Use Convex File Storage instead
❌ Prop-Drilling db or ctx
- Never pass
dborctxobjects from one function to another - Use
ctx.runQueryorctx.runMutationinstead - Preserves dependency tracking and caching
❌ Calling Convex functions from Convex functions
- Extract to helper functions in
convex/model/ - Direct function calls instead of
api.*calls within Convex
Security Anti-Patterns
❌ User-provided data for auth
- Always use
ctx.auth.getUserIdentity()
❌ No argument validators
- All public functions need validators
❌ Exposing internal functions publicly
- Use
internal.*for admin/scheduled functions
❌ Hardcoded environment values
- Use environment variables via dashboard
Migration Anti-Patterns
❌ Breaking schema changes without migration
- Always additive changes first (optional fields)
❌ No backfill strategy
- Plan how to migrate existing data
❌ Testing migrations directly in production
- Use dev/preview environments
Architecture Anti-Patterns
❌ Poor naming (Manager/Util/Helper)
- Use intention-revealing names
❌ Business logic in query/mutation handlers
- Extract to
convex/model/helpers
❌ Duplicate code across functions
- Share logic via helper functions
8. Environment Management
Deployment Flow: Dev → Preview → Prod
Local dev (convex dev)
→ Push to branch (preview deployment)
→ Merge to main (production deployment)
Environment-Specific Patterns
Local development:
convex dev # Hot reload, separate backend
Preview deployments (automatic per branch):
git push origin feature-branch
# Preview deployment auto-created
# Test migrations, features in isolation
Production:
git merge to main
# Auto-deploys to production
Environment Variables
Use dashboard for configuration (not .env files in convex/).
✅ Best practice:
export const sendEmail = action({
handler: async (ctx, { to, subject, body }) => {
const apiKey = process.env.SENDGRID_API_KEY; // ✅ From dashboard
await sendEmail({ apiKey, to, subject, body });
},
});
❌ Anti-pattern:
export const sendEmail = action({
handler: async (ctx, { to, subject, body }) => {
const apiKey = "sk-..."; // ❌ Hardcoded!
await sendEmail({ apiKey, to, subject, body });
},
});
Testing Migrations Across Environments
✅ Best practice flow:
# 1. Test locally
convex dev
# Apply migration, verify locally
# 2. Test in preview
git push origin feature-branch
# Preview deployment auto-created
# Verify migration works with preview data
# 3. Deploy to prod
git merge to main
# Auto-deploys to production
❌ Anti-pattern:
- Testing migrations directly in production
- No preview/staging environment
- Schema changes without backfill plan
9. Quick Reference
Cost Optimization Checklist
- Index what you query - no full table scans
- Paginate all unbounded queries
- Separate hot and cold data - split frequently-updated fields
- Batch mutations - reduce network overhead
- Monitor Functions Log - identify slow queries
- Consider embeddings placement - separate table only when necessary
Embeddings Decision Tree
Need vector search?
→ One embedding per doc + small source? → Co-locate (same table)
→ Multiple embeddings per doc? → Separate table
→ Very large source docs (>100KB)? → Separate table
→ Frequently fetch docs WITHOUT embeddings? → Separate table
→ Otherwise → Co-locate (default)
New Function Checklist
- Argument validators defined (
v.object,v.id, etc.) - Access control implemented (
ctx.auth.getUserIdentity()) - Use internal functions for scheduling/
ctx.run* - Error handling with
ConvexErrorfor user-facing errors - Proper function type (query/mutation/action/internal)
- Indexes exist for query patterns
- No unbounded
.collect()calls
Migration Checklist
- Changes are additive (new fields optional with
v.optional()) - "Expand and Contract" pattern planned
- Backfill strategy defined (internal action)
- Tested in dev environment first
- Tested in preview environment
- Rollback plan defined
- Breaking changes have deprecation period
- Use Convex Migration Component for large datasets
Performance Review Checklist
- Indexes exist for all query patterns
- Compound indexes match filter order
- No unbounded
.collect()calls - Pagination used for large result sets
- Query segmentation for cache optimization
- Hot/cold data separated
- Minimal
ctx.runQuery/runMutationfrom actions - Use
.withIndex()not.filter()on queries - Proper
.unique()vs.first()usage - Embeddings placement optimized for usage pattern
Security Review Checklist
- All public functions have argument validators
- Auth uses
ctx.auth(never user-provided data) - Internal functions used for scheduling/
ctx.run* - No sensitive data in client-visible errors
- Environment variables used (not hardcoded)
- Input sanitization for user-provided content
- ConvexError used for structured error responses
- Row-Level Security (RLS) or RBAC implemented
Philosophy
Cost First
Bandwidth is often the largest cost. Index aggressively, paginate everything, separate hot/cold data, batch operations. Monitor Functions Log religiously.
Embeddings Strategy
Default to co-location. Only separate embeddings when you have clear reasons: multiple embeddings per doc, huge source docs, or frequent fetches without embeddings. Separation adds complexity — justify the trade-off.
Security First
Never trust client input. Always validate. Always use ctx.auth for authentication. Centralize authorization logic.
Deep Modules
Simple interfaces (thin query/mutation wrappers) hiding powerful implementations (convex/model/ helpers).
Performance by Design
Create indexes from day one. Use pagination by default. Segment queries by update frequency. Separate hot and cold data.
Type Safety End-to-End
Convex provides type safety from database → functions → UI. Leverage it fully.
Safe Migrations
Always use "Expand and Contract" pattern. Test in dev/preview. Have rollback strategy. Never break production.
Environment Discipline
Separate deployments for dev/preview/prod. Configuration via dashboard, not code. Test migrations before deploying.
Reactivity is Power
Use useQuery for real-time updates. Don't forfeit Convex's core strength with one-off useEffect calls.
Simplicity Over Abstraction
Start with simple patterns. Add abstraction only when proven necessary. Helper functions over complex frameworks.
This skill represents battle-tested Convex patterns updated for November 2025. Apply these principles to build secure, performant, cost-effective, maintainable backends.