Day 7 — Production
The one idea. "It works" is a statement about your seed data. Production is a different claim, and almost every Convex incident traces back to a read that was unbounded, an endpoint that was public, or a job that was scheduled against
api.*.
#1. Bound every read
Go through every query you have written and classify it:
await ctx.db.query("links").collect(); // ❌ unbounded
await ctx.db.query("links").withIndex(...).collect(); // ✅ bounded by the index range
await ctx.db.query("links").withIndex(...).take(50); // ✅ hard cap
await ctx.db.query("links").withIndex(...).paginate(opts); // ✅ scalesThe middle one deserves a second look: an index bounds the read only if the range is
small. .withIndex("by_owner", q => q.eq("ownerId", id)).collect() is fine for a user with
40 links and not fine for the one who imported 90,000 bookmarks. Bounded-in-practice is not
bounded.
const { results, status, loadMore } = usePaginatedQuery(
api.links.paginatedList, { ownerId }, { initialNumItems: 20 }
);#2. Index audit
For each index: does a query actually name it? If not, delete it — it costs write throughput and storage for nothing.
Then check for prefixes. If you have both by_owner and by_owner_and_archived, the first
is redundant, because by_owner_and_archived already serves an ownerId-only lookup:
.withIndex("by_owner_and_archived", (q) => q.eq("ownerId", id))#3. Public surface audit
List every export in convex/. For each one, ask: should a stranger be able to call
this?
- Called only by an action, cron, or scheduler →
internalQuery/internalMutation/internalAction. - Genuinely client-facing → it needs
argsvalidators and the two Day 5 checks.
Then grep for api. inside convex/ itself. Every hit is a scheduler or action target that
should probably be internal., and each one is an endpoint you did not mean to publish.
#4. Date.now() in queries
// ❌ non-deterministic — defeats caching, results drift
.withIndex("by_released_at", (q) => q.lte("releasedAt", Date.now())).take(100);
// ✅ deterministic — a scheduled job flips the flag, the query reads it
.withIndex("by_is_released", (q) => q.eq("isReleased", true)).take(100);Queries must be deterministic to be cached and re-run (Day 1). Date.now() breaks the
premise. Either pass the time in as an argument from the client, or — better for
"published/expired" logic — keep a boolean the scheduler maintains and index on that.
#5. Components
Sandboxed sub-backends with their own tables, installed from npm:
// convex/convex.config.ts
import { defineApp } from "convex/server";
import agent from "@convex-dev/agent/convex.config.js";
const app = defineApp();
app.use(agent);
export default app;import { components } from "./_generated/api.js";
await ctx.runQuery(components.agent.threads.getThread, { /* ... */ });The isolation is the feature: a component cannot read your tables unless you hand it the data. Installing one is not the same trust decision as installing an npm package that runs inside your code. Browse https://convex.dev/components.
#6. Environments and deploy
- Dev and production are separate deployments with separate data. Nothing you did in dev exists in prod.
- Environment variables are set per-deployment in the dashboard, not in
.envfor the backend. Setting them in dev and forgetting prod is the most common first-deploy failure. npx convex deployfor production. Wire it into your host's build command so the backend and frontend ship together.- Schema changes are validated against existing data. A field that goes from optional to required will be rejected while old documents violate it — the migration is: add optional → backfill → make required.
#7. Cost shape
Worth understanding before it surprises you. You are billed around function calls, database bandwidth and storage. The reactive model means a query re-runs whenever data it read changes. So a query that reads a large slice of a hot table is not just slow — it is repeatedly slow, once per write.
The fix is the same as the correctness fix: narrow the read. Index precisely, paginate, and don't subscribe a component to more data than it renders.
#Quick check 1
.withIndex("by_owner", q => q.eq("ownerId", id)).collect(). Safe?Answer
Only if you can bound how many links one owner has — and you usually can't. The index makes the read efficient, not small. Efficiently loading 90,000 rows into memory is still loading 90,000 rows. Paginate.
#Quick check 2
Why is a
Date.now()query worse than merely inaccurate?Answer
It breaks determinism, which is the foundation for caching and for re-running queries to compute reactive updates. The result isn't just possibly-stale; the whole caching story stops working. Use a scheduler-maintained flag and index on it.
#Quick check 3
Your app works in dev, deploys clean, and 500s in production on a Stripe call.
Answer
Almost certainly an environment variable set on the dev deployment and never on production. They're separate deployments with separate config. Check the prod deployment's environment variables in the dashboard.
#Build task — the audit
Work through the link saver systematically and write down what you find:
- Reads. Every
.collect()justified or replaced. AddusePaginatedQueryto the list. - Indexes. Every index named by a query; every redundant prefix deleted.
- Public surface. Every export classified.
grep -rn "api\." convex/and fix each hit. - Auth. Every client-facing function has both Day 5 checks. Re-run the Day 5 attack.
- Determinism. No
Date.now()in any query. - Promises.
no-floating-promiseson, and clean. - Deploy.
npx convex deploy, environment variables set on production, and exercise the whole app against it.
Keep that checklist. It is reusable on every Convex project you build, and it is the actual deliverable of this bootcamp — more than the app is.
#Checkpoint
- Why can an indexed
.collect()still be a production problem? - How do you find endpoints you accidentally made public?
- Why does
Date.now()in a query break more than accuracy? - What does a component's sandboxing actually prevent?
- Why does the reactive model punish an over-broad query more than a request/response model would?
#Cheat strip
reads : never bare .collect() · index + .take(n) · usePaginatedQuery for real lists
indexes : unused → delete · prefix of a composite → delete
public : grep -rn "api\." convex/ → each hit should probably be internal.*
determ. : no Date.now() in queries — pass it in, or index a scheduler-maintained flag
deploy : dev ≠ prod (separate data AND env vars) · npx convex deploy
schema : optional → backfill → required (never straight to required)
cost : a query re-runs on every write it reads → narrow reads are cheap reads#You're done. Now what?
You can now read the Convex docs without translation, and — more usefully — you know which parts of a 2024 tutorial to distrust.
Three honest next steps:
- Ship something small and real. A tool you personally use, with auth and one third-party API. The bootcamp app has no users; the lessons that remain only arrive with users.
- Read
../reference/gotchas.mdagain. It reads differently now. Several entries will describe something you did this week. - Then, and only then, the AI components.
@convex-dev/agentand the rest are a real part of the stack — but they're built on the primitives you just learned, and they are a miserable place to learn those primitives.
And the Day 1 question, now that you can actually answer it: was the claim true? Write down what Convex removed from your codebase and what it cost you. That judgment — held with specifics rather than vibes — is what separates knowing a tool from having an opinion about one.