---
name: convex-best-practices
description: Production patterns for Convex apps and the rules the @convex-dev/eslint-plugin enforces: validators, indexes, idempotent mutations, avoiding OCC conflicts, thin function wrappers, error handling. Use when reviewing Convex code, asking whether a pattern is right, setting up ESLint, or fixing write conflicts and slow queries.
---

# Convex best practices

The patterns that keep a Convex app fast and correct in production. The rule that matters most: read as little as possible before you write, and read through an index.

## Rules that matter most

1. Validators on every function. `args` and `returns`, with `returns: v.null()` when nothing comes back.
2. Indexes, not filters. Every table read goes through `withIndex` against an index in `convex/schema.ts`.
3. Idempotent mutations. Return early when the document is already in the target state so retries are safe.
4. Patch without reading first. `ctx.db.patch(id, fields)` throws if the doc is missing; you rarely need the old value.
5. `Promise.all` for independent writes. Do not await them one at a time.
6. Schedule `internal.*` only. Crons and `ctx.scheduler` run without a client, so public targets skip auth.
7. Thin wrappers. Auth and business logic live in plain helpers that take `ctx`.
8. `ConvexError` for anything a client should read. Plain `Error` messages are redacted in production.

```typescript
// convex/tasks.ts
import { query, mutation } from "./_generated/server";
import { v, ConvexError } from "convex/values";

const taskValidator = v.object({
  _id: v.id("tasks"),
  _creationTime: v.number(),
  userId: v.id("users"),
  title: v.string(),
  status: v.union(v.literal("open"), v.literal("done")),
});

export const listOpen = query({
  args: { userId: v.id("users") },
  returns: v.array(taskValidator),
  handler: async (ctx, args) => {
    return await ctx.db
      .query("tasks")
      .withIndex("by_user_and_status", (q) =>
        q.eq("userId", args.userId).eq("status", "open"),
      )
      .order("desc")
      .take(100);
  },
});

export const rename = mutation({
  args: { taskId: v.id("tasks"), title: v.string() },
  returns: v.null(),
  handler: async (ctx, args) => {
    if (args.title.trim().length === 0) {
      throw new ConvexError("Title cannot be empty");
    }
    await ctx.db.patch(args.taskId, { title: args.title.trim() });
    return null;
  },
});
```

The schema behind that index:

```typescript
tasks: defineTable({
  userId: v.id("users"),
  title: v.string(),
  status: v.union(v.literal("open"), v.literal("done")),
})
  .index("by_user", ["userId"])
  .index("by_user_and_status", ["userId", "status"]),
```

Name indexes after their fields in order, and query fields in that same order.

## OCC and write conflicts

Convex runs mutations under optimistic concurrency control. A mutation records what it read. If another mutation commits a change to any of that data first, Convex retries it. After enough retries it fails and the client sees a write conflict error.

Conflicts come from three places:

- Two mutations writing the same document at once: counters, "last seen" fields, a shared settings doc
- A mutation that reads a wide range, such as `.collect()` on a whole table, so any change in that range conflicts with it
- A client calling the same mutation faster than it can commit: typing, dragging, polling

### Idempotent and patch first

```typescript
export const complete = mutation({
  args: { taskId: v.id("tasks") },
  returns: v.null(),
  handler: async (ctx, args) => {
    const task = await ctx.db.get(args.taskId);
    if (!task || task.status === "done") {
      return null;
    }
    await ctx.db.patch(args.taskId, { status: "done" });
    return null;
  },
});

export const reorder = mutation({
  args: { itemIds: v.array(v.id("items")) },
  returns: v.null(),
  handler: async (ctx, args) => {
    await Promise.all(
      args.itemIds.map((id, index) => ctx.db.patch(id, { order: index })),
    );
    return null;
  },
});
```

The read in `complete` is fine: one document, early exit. `reorder` never reads at all.

### Event records instead of counters

A counter field on one document is the most common conflict source. Insert one row per event and count in a query.

```typescript
export const trackView = mutation({
  args: { pageId: v.id("pages") },
  returns: v.null(),
  handler: async (ctx, args) => {
    await ctx.db.insert("pageViews", { pageId: args.pageId });
    return null;
  },
});

export const viewCount = query({
  args: { pageId: v.id("pages") },
  returns: v.number(),
  handler: async (ctx, args) => {
    const views = await ctx.db
      .query("pageViews")
      .withIndex("by_page", (q) => q.eq("pageId", args.pageId))
      .collect();
    return views.length;
  },
});
```

Once the event table gets large, move the count to the `@convex-dev/sharded-counter` or `@convex-dev/aggregate` component instead of collecting rows.

### Dedup windows

For heartbeats and presence, skip the write when the last one was recent. Pair it with a client side debounce: 300 to 500 ms for typing, a few seconds for heartbeats.

```typescript
const DEDUP_MS = 10_000;

export const heartbeat = mutation({
  args: { sessionId: v.string(), path: v.string() },
  returns: v.null(),
  handler: async (ctx, args) => {
    const now = Date.now();
    const existing = await ctx.db
      .query("sessions")
      .withIndex("by_session", (q) => q.eq("sessionId", args.sessionId))
      .unique();
    if (!existing) {
      await ctx.db.insert("sessions", { ...args, lastSeen: now });
      return null;
    }
    if (existing.path === args.path && now - existing.lastSeen < DEDUP_MS) {
      return null;
    }
    await ctx.db.patch(existing._id, { path: args.path, lastSeen: now });
    return null;
  },
});
```

Put hot fields such as `lastSeen` in their own table so those writes do not conflict with reads of the stable document.

## Pagination over collect

`.collect()` on an unbounded table gets slower every day and eventually hits read limits. Paginate anything a user can grow. `postValidator` below is a hoisted document validator like `taskValidator` above.

```typescript
import { paginationOptsValidator } from "convex/server";

export const feed = query({
  args: { userId: v.id("users"), paginationOpts: paginationOptsValidator },
  returns: v.object({
    page: v.array(postValidator),
    isDone: v.boolean(),
    continueCursor: v.string(),
    splitCursor: v.optional(v.union(v.string(), v.null())),
    pageStatus: v.optional(
      v.union(v.literal("SplitRecommended"), v.literal("SplitRequired"), v.null()),
    ),
  }),
  handler: async (ctx, args) => {
    return await ctx.db
      .query("posts")
      .withIndex("by_user", (q) => q.eq("userId", args.userId))
      .order("desc")
      .paginate(args.paginationOpts);
  },
});
```

On the client, `usePaginatedQuery` from `convex/react` drives `loadMore`.

## No Date.now() in queries

Queries must be deterministic so Convex can cache them and rerun them when data changes. Pass time from the client, or store a status field that a scheduled mutation updates.

```typescript
export const dueBefore = query({
  args: { userId: v.id("users"), now: v.number() },
  returns: v.array(taskValidator),
  handler: async (ctx, args) => {
    return await ctx.db
      .query("tasks")
      .withIndex("by_user_and_due", (q) =>
        q.eq("userId", args.userId).lt("dueAt", args.now),
      )
      .take(100);
  },
});
```

Mutations and actions may call `Date.now()`.

## ESLint plugin

`@convex-dev/eslint-plugin` catches the old function syntax, missing arg validators, `.filter()` in queries, and top of the hour crons at lint time. Install it in every Convex project.

```bash
npm i --save-dev @convex-dev/eslint-plugin
```

```js
// eslint.config.js
import { defineConfig } from "eslint/config";
import convexPlugin from "@convex-dev/eslint-plugin";

export default defineConfig([...convexPlugin.configs.recommended]);
```

Open [references/eslint-setup.md](references/eslint-setup.md) when you need the full rule list, the TypeScript aware config, package scripts, or a custom `convex/` directory.

## Common mistakes

| Mistake | Why it breaks | Do instead |
| --- | --- | --- |
| `.filter()` on a table query | Reads every row, then drops most | Add an index, use `withIndex` |
| `.collect()` on an unbounded table | Slower every day, hits read limits, wide OCC footprint | `.paginate()` or `.take(n)` |
| Read, compute, then patch a shared doc | Two clients read the same version and both write | Patch directly, or split into event rows |
| Counter field incremented per event | Every increment conflicts with every other | Event records or a sharded counter component |
| Mutation without an early return | Retries and double clicks apply the change twice | Check state, return `null` if already done |
| Sequential `await` on independent writes | Slow, and each read widens the conflict window | `Promise.all` |
| `Date.now()` in a query | Result changes every ms, cache and subscriptions break | Pass `now` as an arg |
| Scheduling `api.*` | Public function runs with no client auth | Schedule `internal.*` |
| Plain `Error` for user messages | Redacted to "Server Error" in production | `ConvexError` |
| Business logic inside the handler | Untestable, duplicated across functions | Plain helper that takes `ctx` |

## Checklist

- [ ] Every function has `args` and `returns`
- [ ] Every table read uses `withIndex`, never `.filter()`
- [ ] Indexes are named after their fields in order
- [ ] Mutations return early when the doc is already in the target state
- [ ] Mutations patch without a prior read unless the old value is needed
- [ ] Independent writes run under `Promise.all`
- [ ] High frequency counts use event rows or a counter component
- [ ] Unbounded lists use `.paginate()`
- [ ] No `Date.now()` inside a query
- [ ] Scheduled and cron targets are `internal.*`
- [ ] `@convex-dev/eslint-plugin` is installed and `npm run lint` passes

## Docs

- https://docs.convex.dev/llms.txt
- https://docs.convex.dev/understanding/best-practices/
- https://docs.convex.dev/database/advanced/occ
- https://docs.convex.dev/database/pagination
- https://docs.convex.dev/eslint
