Skip to content

Fix: Convex Not Working — Query Not Updating, Mutation Throwing Validation Error, or Action Timing Out

FixDevs ·

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-render

Or 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 defaultuseQuery subscribes 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., Date objects 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.db is only available in queries and mutations, not actions — actions use ctx.runQuery and ctx.runMutation to 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 indefinitelyundefined 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.

F

FixDevs

Solo developer based in Japan. Every solution is cross-referenced with official documentation and tested before publishing.

Was this article helpful?

Related Articles