Skip to content

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

FixDevs · (Updated: )

Part of:  JavaScript & TypeScript Errors

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, and most “not working” symptoms come from confusing it with a REST API. Three things separate it from what you might expect.

First, queries are reactive subscriptions, not one-shot fetches. useQuery opens a websocket subscription and the Convex server tracks which documents and indexes the query touched. When any of those change, the server pushes a new result. If a mutation writes to table A but your query reads from table B, the query never re-runs. If your query filters on published: true and the mutation creates a document with published: false, the query also doesn’t re-run, because the new document doesn’t match the index range the query subscribed to. This is by design — it’s what makes Convex fast — but it surprises everyone the first time.

Second, schema validation runs at insert/patch/replace time, not at compile time. TypeScript will happily accept new Date() for a v.number() field because both are “object-ish” in the wrong direction. Convex catches it at runtime with Validator mismatch for field "createdAt". The same applies to optional fields written as null, missing required fields, and union types where the value doesn’t match any branch. The fix is almost always reading the validator definition in schema.ts and matching it exactly.

Third, the three function types have different capabilities. Queries are pure reads inside a database transaction. Mutations are transactional reads + writes. Actions run outside the transaction, can call external APIs, have no direct ctx.db, and time out after 120 seconds. Trying to fetch from a third-party API inside a mutation will fail because mutations can’t perform network I/O. Trying to call ctx.db.insert inside an action will fail because actions don’t have ctx.db — they use ctx.runMutation instead.

Diagnostic Timeline

Minute 0 — Query won’t update after a mutation. Your first instinct is to redeploy or restart npx convex dev. It rarely helps. Open the Convex dashboard’s Logs tab and watch what happens when you trigger the mutation. If the mutation logs but the query doesn’t re-run, the subscription scope doesn’t include what the mutation changed.

Minute 2 — Compare the table names. Open the query handler and the mutation handler side by side. Confirm both touch the same table name as a literal string. A typo like 'Posts' vs 'posts' will silently produce two unrelated subscriptions.

Minute 5 — Check the filter range. If your query is withIndex('by_user', q => q.eq('userId', currentUserId)), the mutation must produce a row with the same userId. Convex re-runs the query only when a document inside the subscribed index range changes. Inserting a row with userId: undefined or a different user won’t trigger it.

Minute 8 — Check auth context. If your query reads ctx.auth.getUserIdentity() and returns early when null, but the React app hasn’t finished authenticating, the query returns the unauthenticated result and caches it. After login, the subscription updates automatically — but only if you read auth inside the handler. Confirm ConvexProviderWithClerk (or Auth0) wraps ConvexProvider, not the other way around.

Minute 12 — Mutation throws a validator error. Read the error message. It tells you the field name and the expected type. The most common offenders are createdAt: new Date() (should be Date.now()), value: null for a non-optional field, and union types where you pass a string that isn’t in the literal set. Open schema.ts and align the value with the validator.

Minute 18 — Optimistic update appears then disappears. If you used useMutation with optimistic updates and the row reverts after the server responds, the server-side mutation rejected silently or wrote a different shape than your optimistic write. Check the Logs tab for a validator error — optimistic reverts happen when the actual mutation throws.

Minute 25 — Action times out at exactly 120 seconds. Actions have a hard 120-second wall-clock limit. If you’re polling a Stripe webhook or running a long batch import, split it into multiple scheduled mutations using ctx.scheduler.runAfter(0, ...) so each step gets its own 120-second budget.

Minute 35 — Function disappears from api.foo.bar. After adding a function, convex/_generated/api.ts only updates when npx convex dev is running and successfully compiles the file. If it errored on save, the generated API stays at the old shape. Read the dev server output for the actual TypeScript error.

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.

Optimistic update flickers back to the old value — Convex applies optimistic writes locally, then reconciles when the server response arrives. If the server mutation throws (validator error, unique constraint, auth failure), the optimistic write is reverted. Wrap your mutation call in a try/catch and inspect the rejection — the error usually contains the real cause. Don’t blame React or the optimistic API itself.

ctx.auth.getUserIdentity() returns null in production but works locally — your Clerk or Auth0 issuer URL in convex/auth.config.ts doesn’t match the JWT issuer the production app is sending. Run npx convex env list --prod to confirm the deployed auth.config.ts references the production Clerk instance, not the dev instance. The issuer URL must end in the exact frontend API domain Clerk gives you.

Schema migration fails after editing schema.ts — Convex enforces schema on deploy. If existing documents don’t satisfy the new schema (you added a non-optional field), the deploy is rejected. Either backfill the field with a mutation that runs before the deploy, mark the new field v.optional(...), or use schema.ts’s schemaValidation: false temporarily while you migrate data.

For related backend database issues, see Fix: Supabase Not Working, Fix: Prisma Migration Failed, Fix: Clerk Not Working, and Fix: Drizzle ORM Not Working.

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