Skip to content
Backend
Skill

/convex-best-practices

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

BOOST
From plugin
builder-skills
40619 skills
Install
$ npx -y skills add waynesutton/builder-skills --skill convex-best-practices --agent claude-code

How it fires

How this skill gets triggered: by you, by Claude, or both.

  • Fires itselfAuto-invocation. Claude auto-loads it when your prompt matches the work.Auto-invocation is when the right skill fires by itself at the right moment, driven by a FLOW.md router and a hook, instead of you invoking it by name. It is the difference between a skill being installed and a skill actually getting used.Read the full definition →
  • You can call itInvoke it directly when you want it.
  • Slash command/convex-best-practices

Context preview

The summary Claude sees to decide when to auto-load this skill.

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

SKILL.md

convex-best-practices.SKILL.md
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.

// 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:

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

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.

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.

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 un

Read more
Ships withbuilder-skills

Agent skills for builders shipping Convex apps. By Wayne Sutton. Seventeen skills.

Get the whole plugin

Other skills on builder-skills.