◆ Convex Stack Bootcamp

Gotchas

Ordered by how much time they cost the average newcomer, worst first. Each one is written as symptom → cause → fix, because that is how you will meet them.


#1. Tutorial code doesn't compile: ctx.db.get(id)

Symptom. You copy a 2024/2025 video line-for-line and TypeScript rejects the database calls.

Cause. The document API now takes the table name first: ctx.db.get("tasks", id), ctx.db.patch("tasks", id, {...}), ctx.db.delete("tasks", id). Video tutorials predate this and use the single-argument form.

Fix. Add the table name. More importantly: internalise that every video in this repo is a snapshot, and the docs are the moving target. See api-cheatsheet.md.


#2. "My UI doesn't update" — the fetchQuery trap

Symptom. Data renders correctly on load, then never changes, even though the reactive demo worked yesterday.

Cause. You used fetchQuery in a Server Component. It is a one-shot read. The page is a photograph, not a window.

Fix. preloadQuery in the Server Component, usePreloadedQuery in the Client Component. You get SSR and liveness. This failure is silent — nothing errors, the page just quietly stops being reactive — which is what makes it expensive.


#3. Loading states: there is no isLoading

Symptom. Cannot read properties of undefined on first render.

Cause. useQuery returns undefined while loading and you destructured it immediately.

Fix. if (data === undefined) return <Skeleton />. Note the design: a query that returns undefined is converted to null on the client precisely so that undefined can mean exactly one thing — "not here yet."


#4. .filter() that works fine right up until it doesn't

Symptom. Everything is fast in development. Production gets slow, then starts erroring on read limits.

Cause. .filter() scans and then discards. With 50 seed rows it is invisible. With 500,000 real rows it is the whole problem. Same for an unbounded .collect().

Fix. .withIndex() with an index declared in schema.ts; or .take(n); or .paginate(). Design the index at the same time you design the table — retrofitting indexes means rewriting queries.


#5. Trusting an argument for identity

Symptom. No symptom. That is the problem.

Cause. handler: async (ctx, { userId }) => ctx.db.patch("teams", userId, ...). Arguments come from the client. The client is a stranger.

Fix. ctx.auth.getUserIdentity(), every time, and check membership/ownership before the write — not just "is anyone logged in." Public Convex functions are public HTTP endpoints. Treat them exactly as you would treat one.


#6. Scheduling or cron-ing a public function

Symptom. None, until someone finds it.

Cause. crons.daily(..., api.messages.sendMessage, ...). Anything reachable as api.* is callable by anyone.

Fix. internalMutation / internalAction, referenced as internal.*. Convex enforces that internal functions can only be called from inside Convex. Same rule for every ctx.scheduler, ctx.runQuery and ctx.runMutation target.


#7. "use node" in a file that also exports a query

Symptom. Deploy fails.

Cause. A file with the "use node" directive can only contain actions.

Fix. Split it. Convention: convex/foo.ts for queries/mutations, convex/fooActions.ts (with "use node") for the Node-only action, and let the action call back in via ctx.runMutation(internal.foo.save, ...).


#8. Sequential ctx.runQuery calls inside an action

Symptom. Rare, baffling inconsistencies — two values that should agree, don't.

Cause. Each ctx.runQuery / ctx.runMutation is its own transaction. Three calls give you three different snapshots of a moving database.

Fix. Write one internal query that returns everything the action needs, and call it once. If you need several writes to be atomic, they belong in one mutation.


#9. The dangling promise that breaks the next request

Symptom. An error surfaces in a function that looks completely unrelated, often intermittently.

Cause. An unawaited ctx.db.patch(...) or ctx.scheduler.runAfter(...). The promise outlives its invocation.

Fix. await everything, and turn on the no-floating-promises ESLint rule so the linter catches it instead of production.


#10. Date.now() inside a query

Symptom. Queries re-run constantly, caching does nothing, or results seem nondeterministic.

Cause. Queries are deterministic and cacheable. Date.now() breaks that premise.

Fix. Pass the time in as an argument from the client, or keep a boolean flag on the row (isReleased) that a scheduled function flips, and index on the flag.


#11. Redundant indexes

Symptom. Slower writes, more storage, no faster reads.

Cause. Keeping both by_team and by_team_and_user.

Fix. Delete the prefix index. by_team_and_user already serves a team-only query — .withIndex("by_team_and_user", (q) => q.eq("team", teamId)) is valid.


#12. Reaching for an action when a mutation would do

Symptom. Lost transactionality, no automatic retry, more code.

Cause. Assuming "it writes to the database and does a thing" means action.

Fix. Ask one question: does this need the network? No → mutation. Yes → action that delegates its database work to internal mutations. Actions are the escape hatch, not the default.