Fix: Convex Not Working — Query Not Updating, Mutation Throwing Validation Error, or Action Timing Out
Quick Answer
How to fix Convex backend issues — query/mutation/action patterns, schema validation, real-time reactivity, file storage, auth integration, and common TypeScript type errors.
The Problem
A Convex query returns undefined while the data is loading, then doesn’t update when the underlying data changes:
const users = useQuery(api.users.list);
// users is undefined initially, then an array — but subsequent mutations don't trigger a re-renderOr a mutation throws a validation error that’s hard to decipher:
ConvexError: [CONVEX M(users:create)] Uncaught Error: Validator mismatch for field "createdAt".
Expected `number`, got `string`.Or an action times out unexpectedly:
ConvexError: [CONVEX A(stripe:createCheckout)] Uncaught Error: Action timed out after 120 seconds.Or TypeScript shows type errors when calling Convex functions:
await ctx.db.insert('users', { name: 'Alice', email: '[email protected]' });
// Type error: Argument of type '...' is not assignable to parameter of type 'WithoutSystemFields<...'Why This Happens
Convex is a reactive backend — it has specific patterns that differ from REST APIs:
- Queries are reactive by default —
useQuerysubscribes to live updates. If the data doesn’t update, it usually means the mutation isn’t writing to the same table the query reads from, or the query has a different filter. - Schema validation is strict — Convex validates every insert and patch against your schema at runtime. Type mismatches that TypeScript doesn’t catch (e.g.,
Dateobjects vs. timestamps) cause runtime errors. - Actions are for external API calls — queries and mutations run inside Convex’s database transactions. Actions run outside transactions (for calling third-party APIs) and have a 120-second timeout. Long-running work should be split into smaller operations or use scheduled functions.
ctx.dbis only available in queries and mutations, not actions — actions usectx.runQueryandctx.runMutationto interact with the database indirectly.
Fix 1: Understand Queries, Mutations, and Actions
// convex/users.ts
// QUERY — read-only, reactive, runs in transaction
// useQuery() in React automatically subscribes and re-renders on changes
export const list = query({
args: {
limit: v.optional(v.number()),
},
handler: async (ctx, args) => {
return await ctx.db
.query('users')
.order('desc')
.take(args.limit ?? 20);
},
});
// With auth — get current user
export const me = query({
args: {},
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) return null;
return await ctx.db
.query('users')
.withIndex('by_token', q => q.eq('tokenIdentifier', identity.tokenIdentifier))
.first();
},
});
// MUTATION — read+write, runs in transaction, triggers query updates
export const create = mutation({
args: {
name: v.string(),
email: v.string(),
},
handler: async (ctx, args) => {
// Validate business logic
const existing = await ctx.db
.query('users')
.withIndex('by_email', q => q.eq('email', args.email))
.first();
if (existing) throw new ConvexError('Email already exists');
return await ctx.db.insert('users', {
name: args.name,
email: args.email,
createdAt: Date.now(), // Use timestamps, not Date objects
});
},
});
// ACTION — can call external APIs, no direct DB access
export const sendWelcomeEmail = action({
args: { userId: v.id('users') },
handler: async (ctx, args) => {
// Get user data via runQuery
const user = await ctx.runQuery(api.users.get, { id: args.userId });
if (!user) throw new Error('User not found');
// Call external service
await sendEmail({
to: user.email,
subject: 'Welcome!',
body: `Hi ${user.name}!`,
});
// Update DB via runMutation
await ctx.runMutation(api.users.markWelcomed, { id: args.userId });
},
});React usage:
// components/UserList.tsx
import { useQuery, useMutation } from 'convex/react';
import { api } from '../convex/_generated/api';
export function UserList() {
// Reactive — updates automatically when data changes
const users = useQuery(api.users.list, { limit: 10 });
const createUser = useMutation(api.users.create);
if (users === undefined) return <Spinner />; // Loading
return (
<div>
<ul>
{users.map(user => (
<li key={user._id}>{user.name}</li>
))}
</ul>
<button onClick={() => createUser({ name: 'Alice', email: '[email protected]' })}>
Add User
</button>
</div>
);
}Fix 2: Define Schema Correctly
Schemas prevent runtime validation errors and enable TypeScript types:
// convex/schema.ts
import { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';
export default defineSchema({
users: defineTable({
name: v.string(),
email: v.string(),
role: v.union(v.literal('user'), v.literal('admin')),
bio: v.optional(v.string()), // Optional field
tokenIdentifier: v.string(), // For auth
createdAt: v.number(), // Always use v.number() for timestamps
settings: v.object({ // Nested object
theme: v.string(),
notifications: v.boolean(),
}),
tags: v.array(v.string()), // Array of strings
})
.index('by_email', ['email']) // Index for fast lookups
.index('by_token', ['tokenIdentifier'])
.searchIndex('search_name', { // Full-text search
searchField: 'name',
filterFields: ['role'],
}),
posts: defineTable({
title: v.string(),
content: v.string(),
authorId: v.id('users'), // Reference to users table
published: v.boolean(),
publishedAt: v.optional(v.number()),
})
.index('by_author', ['authorId'])
.index('by_published', ['published', 'publishedAt']),
// Files stored in Convex storage
files: defineTable({
name: v.string(),
storageId: v.id('_storage'), // Reference to file storage
uploadedBy: v.id('users'),
contentType: v.string(),
}),
});Common validator types:
v.string() // string
v.number() // number (use for all numeric values)
v.boolean() // boolean
v.null() // null
v.id('tableName') // Convex document ID
v.array(v.string()) // string[]
v.object({ key: v.string() }) // { key: string }
v.union(v.literal('a'), v.literal('b')) // 'a' | 'b'
v.optional(v.string()) // string | undefined
v.any() // any (avoid — breaks type safety)Fix 3: Fix Validation Errors
The most common validation errors come from type mismatches:
// WRONG — Date objects aren't supported
await ctx.db.insert('users', {
name: 'Alice',
createdAt: new Date(), // Error: Expected number, got object
});
// CORRECT — use timestamps
await ctx.db.insert('users', {
name: 'Alice',
createdAt: Date.now(), // number (Unix timestamp in milliseconds)
});
// WRONG — undefined for required fields
await ctx.db.insert('users', {
name: 'Alice',
// email: undefined // Error if email is v.string() (not optional)
});
// CORRECT — include all required fields
await ctx.db.insert('users', {
name: 'Alice',
email: '[email protected]',
});
// Updating — use patch for partial updates
await ctx.db.patch(userId, {
name: 'Alice Updated',
// Only include fields you're changing
});
// Replacing — use replace for full document replacement
await ctx.db.replace(userId, {
name: 'Alice Updated',
email: '[email protected]',
role: 'admin',
// Must include ALL fields from schema
createdAt: existingUser.createdAt,
tokenIdentifier: existingUser.tokenIdentifier,
settings: existingUser.settings,
tags: existingUser.tags,
bio: existingUser.bio,
});Throw typed errors for better error handling:
import { ConvexError } from 'convex/values';
// In a mutation
if (existingUser) {
throw new ConvexError({
code: 'DUPLICATE_EMAIL',
message: 'A user with this email already exists',
});
}
// In the client
try {
await createUser({ name, email });
} catch (error) {
if (error instanceof ConvexError) {
const { code, message } = error.data;
setError(message);
}
}Fix 4: Use Indexes for Query Performance
Without indexes, Convex performs a full table scan:
// WRONG — no index, full table scan
const user = await ctx.db
.query('users')
.filter(q => q.eq(q.field('email'), email)) // Scan + filter
.first();
// CORRECT — use withIndex for O(log n) lookup
const user = await ctx.db
.query('users')
.withIndex('by_email', q => q.eq('email', email)) // Uses B-tree index
.first();
// Range queries with indexes
const recentPosts = await ctx.db
.query('posts')
.withIndex('by_published', q =>
q.eq('published', true)
.gt('publishedAt', Date.now() - 7 * 24 * 60 * 60 * 1000) // Last 7 days
)
.order('desc')
.take(10);
// Full-text search
const results = await ctx.db
.query('posts')
.withSearchIndex('search_title', q =>
q.search('title', searchQuery)
.eq('published', true)
)
.take(20);Fix 5: File Storage
// convex/files.ts
export const generateUploadUrl = mutation({
args: {},
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new ConvexError('Unauthenticated');
// Generate a short-lived URL for uploading
return await ctx.storage.generateUploadUrl();
},
});
export const saveFile = mutation({
args: {
storageId: v.id('_storage'),
name: v.string(),
contentType: v.string(),
},
handler: async (ctx, args) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new ConvexError('Unauthenticated');
const user = await getUserByToken(ctx, identity.tokenIdentifier);
return await ctx.db.insert('files', {
storageId: args.storageId,
name: args.name,
contentType: args.contentType,
uploadedBy: user._id,
});
},
});
export const getFileUrl = query({
args: { storageId: v.id('_storage') },
handler: async (ctx, args) => {
return await ctx.storage.getUrl(args.storageId);
},
});// Client-side upload
async function uploadFile(file: File) {
// 1. Get upload URL from Convex
const uploadUrl = await generateUploadUrl();
// 2. Upload directly to Convex storage
const result = await fetch(uploadUrl, {
method: 'POST',
headers: { 'Content-Type': file.type },
body: file,
});
const { storageId } = await result.json();
// 3. Save reference in database
await saveFile({
storageId,
name: file.name,
contentType: file.type,
});
}Fix 6: Scheduled Functions and Auth
Schedule work for the future:
// convex/notifications.ts
export const scheduleReminder = mutation({
args: {
userId: v.id('users'),
message: v.string(),
sendAt: v.number(), // Timestamp
},
handler: async (ctx, args) => {
// Schedule a function to run at a future time
await ctx.scheduler.runAt(args.sendAt, api.notifications.send, {
userId: args.userId,
message: args.message,
});
},
});
// Recurring jobs via crons
// convex/crons.ts
import { cronJobs } from 'convex/server';
const crons = cronJobs();
crons.interval(
'cleanup expired sessions',
{ minutes: 60 }, // Every hour
api.sessions.cleanup
);
export default crons;Auth with Clerk:
// convex/auth.config.ts
export default {
providers: [
{
domain: 'https://clerk.your-domain.com',
applicationID: 'convex',
},
],
};
// convex/users.ts — create/get user on auth
export const getOrCreate = mutation({
args: {},
handler: async (ctx) => {
const identity = await ctx.auth.getUserIdentity();
if (!identity) throw new ConvexError('Unauthenticated');
const existing = await ctx.db
.query('users')
.withIndex('by_token', q => q.eq('tokenIdentifier', identity.tokenIdentifier))
.first();
if (existing) return existing;
return await ctx.db.insert('users', {
name: identity.name ?? 'Anonymous',
email: identity.email!,
tokenIdentifier: identity.tokenIdentifier,
role: 'user',
createdAt: Date.now(),
settings: { theme: 'light', notifications: true },
tags: [],
});
},
});Still Not Working?
useQuery returns undefined indefinitely — undefined means loading (not null, which would mean no result). If it stays undefined, check: (1) ConvexProvider wraps your app, (2) the function name matches the generated API (api.users.list — check convex/_generated/api.ts), (3) the function is exported from convex/users.ts and the file is named correctly. Run npx convex dev to see if there are compilation errors.
Mutation succeeds but query doesn’t update — Convex queries automatically re-run when the tables they read from change. If your mutation writes to table A and your query reads from table B, the query won’t update. Check that your mutation is inserting/patching/deleting from the same table the query reads. Also verify the query’s filter matches the newly written data.
Function not found at runtime — after adding a new function, you must run npx convex dev to regenerate convex/_generated/api.ts. If you’re importing from the API object before regenerating, the function won’t exist. Always run the dev server when adding new Convex functions.
For related backend database issues, see Fix: Supabase Not Working and Fix: Prisma Migration Failed.
Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.
Was this article helpful?
Related Articles
Fix: Supabase Not Working — RLS Policy Blocking Queries, Realtime Not Receiving Updates, or Auth Session Lost
How to fix Supabase issues — Row Level Security policies, realtime subscriptions, storage permissions, auth session with Next.js, edge functions, and common client configuration mistakes.
Fix: Fastify Not Working — 404, Plugin Encapsulation, and Schema Validation Errors
How to fix Fastify issues — route 404 from plugin encapsulation, reply already sent, FST_ERR_VALIDATION, request.body undefined, @fastify/cors, hooks not running, and TypeScript type inference.
Fix: pdf-lib Not Working — PDF Not Generating, Fonts Not Embedding, or Pages Blank
How to fix pdf-lib issues — creating PDFs from scratch, modifying existing PDFs, embedding fonts and images, form filling, merging documents, and browser and Node.js usage.
Fix: Pusher Not Working — Events Not Received, Channel Auth Failing, or Connection Dropping
How to fix Pusher real-time issues — client and server setup, channel types, presence channels, authentication endpoints, event binding, connection management, and React integration.