| name | convex-anti-patterns |
| description | Critical rules and common mistakes to avoid in Convex development. Use when reviewing Convex code, debugging issues, or learning what NOT to do. Activates for code review, debugging OCC errors, fixing type errors, or understanding why code fails in Convex. |
| allowed-tools | ["Read","Write","Edit","Glob","Grep","Bash"] |
Convex Anti-Patterns & Agent Rules
Overview
This skill documents critical mistakes to avoid in Convex development and rules that agents must follow. Every pattern here has caused real production issues.
TypeScript: NEVER Use any Type
CRITICAL RULE: This codebase has @typescript-eslint/no-explicit-any enabled. Using any will cause build failures.
❌ WRONG:
function handleData(data: any) { ... }
const items: any[] = [];
args: { data: v.any() }
✅ CORRECT:
function handleData(data: Doc<"items">) { ... }
const items: Doc<"items">[] = [];
args: { data: v.object({ field: v.string() }) }
When to Use This Skill
Use this skill when:
- Reviewing Convex code for issues
- Debugging mysterious errors
- Understanding why code doesn't work as expected
- Learning Convex best practices by counter-example
- Checking code against known anti-patterns
Critical Anti-Patterns
Anti-Pattern 1: fetch() in Mutations
Mutations must be deterministic. External calls break this guarantee.
❌ WRONG:
export const createOrder = mutation({
args: { productId: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
const price = await fetch(
`https://api.stripe.com/prices/${args.productId}`,
);
await ctx.db.insert('orders', {
productId: args.productId,
price: await price.json(),
});
return null;
},
});
✅ CORRECT:
export const createOrder = mutation({
args: { productId: v.string() },
returns: v.id('orders'),
handler: async (ctx, args) => {
const orderId = await ctx.db.insert('orders', {
productId: args.productId,
status: 'pending',
});
await ctx.scheduler.runAfter(0, internal.orders.fetchPrice, { orderId });
return orderId;
},
});
export const fetchPrice = internalAction({
args: { orderId: v.id('orders') },
returns: v.null(),
handler: async (ctx, args) => {
const order = await ctx.runQuery(internal.orders.getById, {
orderId: args.orderId,
});
if (!order) return null;
const response = await fetch(
`https://api.stripe.com/prices/${order.productId}`,
);
const priceData = await response.json();
await ctx.runMutation(internal.orders.updatePrice, {
orderId: args.orderId,
price: priceData.unit_amount,
});
return null;
},
});
Anti-Pattern 2: ctx.db in Actions
Actions don't have database access. This is a common source of TypeScript errors.
❌ WRONG:
export const processData = action({
args: { id: v.id('items') },
returns: v.null(),
handler: async (ctx, args) => {
const item = await ctx.db.get(args.id);
return null;
},
});
✅ CORRECT:
export const processData = action({
args: { id: v.id('items') },
returns: v.null(),
handler: async (ctx, args) => {
const item = await ctx.runQuery(internal.items.getById, { id: args.id });
const result = await fetch('https://api.example.com/process', {
method: 'POST',
body: JSON.stringify(item),
});
await ctx.runMutation(internal.items.updateResult, {
id: args.id,
result: await result.json(),
});
return null;
},
});
Anti-Pattern 3: Missing returns Validator
Every function must have an explicit returns validator.
❌ WRONG:
export const doSomething = mutation({
args: { data: v.string() },
handler: async (ctx, args) => {
await ctx.db.insert('items', { data: args.data });
},
});
✅ CORRECT:
export const doSomething = mutation({
args: { data: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.insert('items', { data: args.data });
return null;
},
});
Anti-Pattern 4: Using .filter() on Queries
.filter() scans the entire table. Always use indexes.
❌ WRONG:
export const getActiveUsers = query({
args: {},
returns: v.array(v.object({ _id: v.id('users'), name: v.string() })),
handler: async ctx => {
return await ctx.db
.query('users')
.filter(q => q.eq(q.field('status'), 'active'))
.collect();
},
});
✅ CORRECT:
export const getActiveUsers = query({
args: {},
returns: v.array(v.object({ _id: v.id('users'), name: v.string() })),
handler: async ctx => {
return await ctx.db
.query('users')
.withIndex('by_status', q => q.eq('status', 'active'))
.collect();
},
});
Anti-Pattern 5: Unbounded .collect()
Never collect without limits on potentially large tables.
❌ WRONG:
export const getAllMessages = query({
args: { channelId: v.id('channels') },
returns: v.array(v.object({ content: v.string() })),
handler: async (ctx, args) => {
return await ctx.db
.query('messages')
.withIndex('by_channel', q => q.eq('channelId', args.channelId))
.collect();
},
});
✅ CORRECT:
export const getRecentMessages = query({
args: { channelId: v.id('channels') },
returns: v.array(v.object({ content: v.string() })),
handler: async (ctx, args) => {
return await ctx.db
.query('messages')
.withIndex('by_channel', q => q.eq('channelId', args.channelId))
.order('desc')
.take(50);
},
});
Anti-Pattern 6: .collect().length for Counts
Collecting just to count is wasteful.
❌ WRONG:
export const getMessageCount = query({
args: { channelId: v.id('channels') },
returns: v.number(),
handler: async (ctx, args) => {
const messages = await ctx.db
.query('messages')
.withIndex('by_channel', q => q.eq('channelId', args.channelId))
.collect();
return messages.length;
},
});
✅ CORRECT:
export const getMessageCount = query({
args: { channelId: v.id('channels') },
returns: v.string(),
handler: async (ctx, args) => {
const messages = await ctx.db
.query('messages')
.withIndex('by_channel', q => q.eq('channelId', args.channelId))
.take(100);
return messages.length === 100 ? '99+' : String(messages.length);
},
});
export const getMessageCount = query({
args: { channelId: v.id('channels') },
returns: v.number(),
handler: async (ctx, args) => {
const channel = await ctx.db.get(args.channelId);
return channel?.messageCount ?? 0;
},
});
Anti-Pattern 7: N+1 Query Pattern
Loading related documents one by one.
❌ WRONG:
export const getPostsWithAuthors = query({
args: {},
returns: v.array(
v.object({
post: v.object({ title: v.string() }),
author: v.object({ name: v.string() }),
}),
),
handler: async ctx => {
const posts = await ctx.db.query('posts').take(10);
const postsWithAuthors = await Promise.all(
posts.map(async post => ({
post: { title: post.title },
author: await ctx.db.get(post.authorId).then(a => ({ name: a!.name })),
})),
);
return postsWithAuthors;
},
});
✅ CORRECT:
import { getAll } from 'convex-helpers/server/relationships';
export const getPostsWithAuthors = query({
args: {},
returns: v.array(
v.object({
post: v.object({ title: v.string() }),
author: v.union(v.object({ name: v.string() }), v.null()),
}),
),
handler: async ctx => {
const posts = await ctx.db.query('posts').take(10);
const authorIds = [...new Set(posts.map(p => p.authorId))];
const authors = await getAll(ctx.db, authorIds);
const authorMap = new Map(
authors
.filter((a): a is NonNullable<typeof a> => a !== null)
.map(a => [a._id, a]),
);
return posts.map(post => ({
post: { title: post.title },
author: authorMap.get(post.authorId)
? { name: authorMap.get(post.authorId)!.name }
: null,
}));
},
});
Anti-Pattern 8: Global Counter (Hot Spot)
Single document updates cause OCC conflicts under load.
❌ WRONG:
export const incrementPageViews = mutation({
args: {},
returns: v.null(),
handler: async ctx => {
const stats = await ctx.db.query('globalStats').unique();
await ctx.db.patch(stats!._id, { views: stats!.views + 1 });
return null;
},
});
✅ CORRECT:
export const incrementPageViews = mutation({
args: {},
returns: v.null(),
handler: async ctx => {
const shardId = Math.floor(Math.random() * 10);
await ctx.db.insert('viewShards', { shardId, delta: 1 });
return null;
},
});
export const getPageViews = query({
args: {},
returns: v.number(),
handler: async ctx => {
const shards = await ctx.db.query('viewShards').collect();
return shards.reduce((sum, s) => sum + s.delta, 0);
},
});
import { Workpool } from '@convex-dev/workpool';
const counterPool = new Workpool(components.workpool, { maxParallelism: 1 });
export const incrementPageViews = mutation({
args: {},
returns: v.null(),
handler: async ctx => {
await counterPool.enqueueMutation(ctx, internal.stats.doIncrement, {});
return null;
},
});
Anti-Pattern 9: Using v.bigint() (Deprecated)
❌ WRONG:
export default defineSchema({
counters: defineTable({
value: v.bigint(),
}),
});
✅ CORRECT:
export default defineSchema({
counters: defineTable({
value: v.int64(),
}),
});
Anti-Pattern 10: Missing System Fields in Return Validators
❌ WRONG:
export const getUser = query({
args: { userId: v.id('users') },
returns: v.object({
name: v.string(),
email: v.string(),
}),
handler: async (ctx, args) => {
return await ctx.db.get(args.userId);
},
});
✅ CORRECT:
export const getUser = query({
args: { userId: v.id('users') },
returns: v.union(
v.object({
_id: v.id('users'),
_creationTime: v.number(),
name: v.string(),
email: v.string(),
}),
v.null(),
),
handler: async (ctx, args) => {
return await ctx.db.get(args.userId);
},
});
Anti-Pattern 11: Public Functions for Internal Logic
❌ WRONG:
export const deleteUserData = mutation({
args: { userId: v.id('users') },
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.delete(args.userId);
return null;
},
});
✅ CORRECT:
export const deleteUserData = internalMutation({
args: { userId: v.id('users') },
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.delete(args.userId);
return null;
},
});
export const requestAccountDeletion = mutation({
args: {},
returns: v.null(),
handler: async ctx => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new Error('Unauthorized');
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');
await ctx.scheduler.runAfter(0, internal.users.deleteUserData, {
userId: user._id,
});
return null;
},
});
Anti-Pattern 12: Non-Transactional Actions for Data Consistency
❌ WRONG:
export const transferFunds = action({
args: { from: v.id('accounts'), to: v.id('accounts'), amount: v.number() },
returns: v.null(),
handler: async (ctx, args) => {
await ctx.runMutation(internal.accounts.debit, {
accountId: args.from,
amount: args.amount,
});
await ctx.runMutation(internal.accounts.credit, {
accountId: args.to,
amount: args.amount,
});
return null;
},
});
✅ CORRECT:
export const transferFunds = mutation({
args: { from: v.id('accounts'), to: v.id('accounts'), amount: v.number() },
returns: v.null(),
handler: async (ctx, args) => {
const fromAccount = await ctx.db.get(args.from);
const toAccount = await ctx.db.get(args.to);
if (!fromAccount || !toAccount) throw new Error('Account not found');
if (fromAccount.balance < args.amount)
throw new Error('Insufficient funds');
await ctx.db.patch(args.from, {
balance: fromAccount.balance - args.amount,
});
await ctx.db.patch(args.to, { balance: toAccount.balance + args.amount });
return null;
},
});
Anti-Pattern 13: Redundant Indexes
❌ WRONG:
export default defineSchema({
messages: defineTable({
channelId: v.id('channels'),
authorId: v.id('users'),
content: v.string(),
})
.index('by_channel', ['channelId'])
.index('by_channel_author', ['channelId', 'authorId']),
});
✅ CORRECT:
export default defineSchema({
messages: defineTable({
channelId: v.id('channels'),
authorId: v.id('users'),
content: v.string(),
})
.index('by_channel_author', ['channelId', 'authorId']),
});
Anti-Pattern 14: Using v.string() for IDs
❌ WRONG:
export const getMessage = query({
args: { messageId: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
return await ctx.db.get(args.messageId as Id<'messages'>);
},
});
✅ CORRECT:
export const getMessage = query({
args: { messageId: v.id('messages') },
returns: v.union(
v.object({
_id: v.id('messages'),
_creationTime: v.number(),
content: v.string(),
}),
v.null(),
),
handler: async (ctx, args) => {
return await ctx.db.get(args.messageId);
},
});
Anti-Pattern 15: Retry Without Backoff or Jitter
❌ WRONG:
export const processWithRetry = internalAction({
args: { jobId: v.id('jobs'), attempt: v.number() },
returns: v.null(),
handler: async (ctx, args) => {
try {
} catch (error) {
if (args.attempt < 5) {
await ctx.scheduler.runAfter(5000, internal.jobs.processWithRetry, {
jobId: args.jobId,
attempt: args.attempt + 1,
});
}
}
return null;
},
});
✅ CORRECT:
export const processWithRetry = internalAction({
args: { jobId: v.id('jobs'), attempt: v.number() },
returns: v.null(),
handler: async (ctx, args) => {
try {
} catch (error) {
if (args.attempt < 5) {
const baseDelay = Math.pow(2, args.attempt) * 1000;
const jitter = Math.random() * 1000;
await ctx.scheduler.runAfter(
baseDelay + jitter,
internal.jobs.processWithRetry,
{
jobId: args.jobId,
attempt: args.attempt + 1,
},
);
}
}
return null;
},
});
Agent Rules Summary
Must Do
- Always include
returns validator on every function
- Always use indexes instead of
.filter()
- Always use
take(n) for potentially large queries
- Always use
v.id("table") for document ID arguments
- Always use
internalMutation/internalAction for sensitive operations
- Always handle errors in actions and update status in database
- Always use exponential backoff with jitter for retries
Must Not Do
- Never call
fetch() in mutations
- Never access
ctx.db in actions
- Never use
.filter() on database queries
- Never use
.collect() without limits on large tables
- Never use
v.bigint() (deprecated, use v.int64())
- Never use
any type (ESLint rule enforced)
- Never write to hot-spot documents without sharding/workpool
- Never expose dangerous operations as public functions
- Never rely on multiple mutations for atomic operations
Quick Checklist
Before submitting Convex code, verify: