Skip to main content

Best Practices

This is a list of best practices and common anti-patterns around using Convex. We recommend going through this list before broadly releasing your app to production. You may choose to try using all of these best practices from the start, or you may wait until you've gotten major parts of your app working before going through and adopting the best practices here.

Await all Promises

Why?

Convex functions use async / await. If you don't await all your promises (e.g. await ctx.scheduler.runAfter, await ctx.db.patch), you may run into unexpected behavior (e.g. failing to schedule a function) or miss handling errors.

How?

We recommend the no-floating-promises eslint rule with TypeScript.

Avoid .filter on database queries

Why?

Filtering in code instead of using the .filter syntax has the same performance, and is generally easier code to write. Conditions in .withIndex or .withSearchIndex are more efficient than .filter or filtering in code, so almost all uses of .filter should either be replaced with a .withIndex or .withSearchIndex condition, or written as TypeScript code.

Read through the indexes documentation for an overview of how to define indexes and how they work.

Examples

convex/messages.ts
// ❌
const tomsMessages = ctx.db
.query("messages")
.filter((q) => q.eq(q.field("author"), "Tom"))
.collect();

// ✅
// Option 1: Use an index
const tomsMessages = await ctx.db
.query("messages")
.withIndex("by_author", (q) => q.eq("author", "Tom"))
.collect();

// Option 2: Filter in code
const allMessages = await ctx.db.query("messages").collect();
const tomsMessages = allMessages.filter((m) => m.author === "Tom");

How?

Search for .filter in your Convex codebase — a regex like \.filter\(\(?q will probably find all the ones on database queries.

Decide whether they should be replaced with a .withIndex condition — per this section, if you are filtering over a large (1000+) or potentially unbounded number of documents, you should use an index. If not using a .withIndex / .withSearchIndex condition, consider replacing them with a filter in code for more readability and flexibility.

See this article for more strategies for filtering.

Exceptions

Using .filter on a paginated query (.paginate) has advantages over filtering in code. The paginated query will return the number of documents requested, including the .filter condition, so filtering in code afterwards can result in a smaller page or even an empty page. Using .withIndex on a paginated query will still be more efficient than a .filter.

Only use .collect with a small number of results

Why?

All results returned from .collect count towards database bandwidth (even ones filtered out by .filter). It also means that if any document in the result changes, the query will re-run or the mutation will hit a conflict.

If there's a chance the number of results is large (say 1000+ documents), you should use an index to filter the results further before calling .collect, or find some other way to avoid loading all the documents such as using pagination, denormalizing data, or changing the product feature.

Example

Using an index:

convex/movies.ts
// ❌ -- potentially unbounded
const allMovies = await ctx.db.query("movies").collect();
const moviesByDirector = allMovies.filter(
(m) => m.director === "Steven Spielberg",
);

// ✅ -- small number of results, so `collect` is fine
const moviesByDirector = await ctx.db
.query("movies")
.withIndex("by_director", (q) => q.eq("director", "Steven Spielberg"))
.collect();

Using pagination:

convex/movies.ts
// ❌ -- potentially unbounded
const watchedMovies = await ctx.db
.query("watchedMovies")
.withIndex("by_user", (q) => q.eq("user", "Tom"))
.collect();

// ✅ -- using pagination, showing recently watched movies first
const watchedMovies = await ctx.db
.query("watchedMovies")
.withIndex("by_user", (q) => q.eq("user", "Tom"))
.order("desc")
.paginate(paginationOptions);

Using a limit or denormalizing:

convex/movies.ts
// ❌ -- potentially unbounded
const watchedMovies = await ctx.db
.query("watchedMovies")
.withIndex("by_user", (q) => q.eq("user", "Tom"))
.collect();
const numberOfWatchedMovies = watchedMovies.length;

// ✅ -- Show "99+" instead of needing to load all documents
const watchedMovies = await ctx.db
.query("watchedMovies")
.withIndex("by_user", (q) => q.eq("user", "Tom"))
.take(100);
const numberOfWatchedMovies =
watchedMovies.length === 100 ? "99+" : watchedMovies.length.toString();

// ✅ -- Denormalize the number of watched movies in a separate table
const watchedMoviesCount = await ctx.db
.query("watchedMoviesCount")
.withIndex("by_user", (q) => q.eq("user", "Tom"))
.unique();

How?

Search for .collect in your Convex codebase (a regex like \.collect\( will probably find these). And think through whether the number of results is small. This function health page in the dashboard can also help surface these.

The aggregate component or database triggers can be helpful patterns for denormalizing data.

Exceptions

If you're doing something that requires loading a large number of documents (e.g. performing a migration, making a summary), you may want to use an action to load them in batches via separate queries / mutations.

Check for redundant indexes

Why?

Indexes like by_foo and by_foo_and_bar are usually redundant (you only need by_foo_and_bar). Reducing the number of indexes saves on database storage and reduces the overhead of writing to the table.

convex/teams.ts
// ❌
const allTeamMembers = await ctx.db
.query("teamMembers")
.withIndex("by_team", (q) => q.eq("team", teamId))
.collect();
const currentUserId = /* get current user id from `ctx.auth` */
const currentTeamMember = await ctx.db
.query("teamMembers")
.withIndex("by_team_and_user", (q) =>
q.eq("team", teamId).eq("user", currentUserId),
)
.unique();

// ✅
// Just don't include a condition on `user` when querying for results on `team`
const allTeamMembers = await ctx.db
.query("teamMembers")
.withIndex("by_team_and_user", (q) => q.eq("team", teamId))
.collect();
const currentUserId = /* get current user id from `ctx.auth` */
const currentTeamMember = await ctx.db
.query("teamMembers")
.withIndex("by_team_and_user", (q) =>
q.eq("team", teamId).eq("user", currentUserId),
)
.unique();

How?

Look through your indexes, either in your schema.ts file or in the dashboard, and look for any indexes where one is a prefix of another.

Exceptions

.index("by_foo", ["foo"]) is really an index on the properties foo and _creationTime, while .index("by_foo_and_bar", ["foo", "bar"]) is an index on the properties foo, bar, and _creationTime. If you have queries that need to be sorted by foo and then _creationTime, then you need both indexes.

For example, .index("by_channel", ["channel"]) on a table of messages can be used to query for the most recent messages in a channel, but .index("by_channel_and_author", ["channel", "author"]) could not be used for this since it would first sort the messages by author.

Use argument validators for all public functions

Why?

Public functions can be called by anyone, including potentially malicious attackers trying to break your app. Argument validators (as well as return value validators) help ensure you're getting the traffic you expect.

Example

convex/messages.ts
// ❌ -- could be used to update any document (not just `messages`)
export const updateMessage = mutation({
handler: async (ctx, { id, update }) => {
await ctx.db.patch(id, update);
},
});

// ✅ -- can only be called with an ID from the messages table, and can only update
// the `body` and `author` fields
export const updateMessage = mutation({
args: {
id: v.id("messages"),
update: v.object({
body: v.optional(v.string()),
author: v.optional(v.string()),
}),
},
handler: async (ctx, { id, update }) => {
await ctx.db.patch(id, update);
},
});

How?

Search for query, mutation, and action in your Convex codebase, and ensure that all of them have argument validators (and optionally return value validators). If you have httpActions, you may want to use something like zod to validate that the HTTP request is the shape you expect.

Use some form of access control for all public functions

Why?

Public functions can be called by anyone, including potentially malicious attackers trying to break your app. If portions of your app should only be accessible when the user is signed in, make sure all these Convex functions check that ctx.auth.getUserIdentity() is set.

You may also have specific checks, like only loading messages that were sent to or from the current user, which you'll want to apply in every relevant public function.

Favoring more granular functions like setTeamOwner over updateTeam allows more granular checks for which users can do what.

Access control checks should either use ctx.auth.getUserIdentity() or a function argument that is unguessable (e.g. a UUID, or a Convex ID, provided that this ID is never exposed to any client but the one user). In particular, don't use a function argument which could be spoofed (e.g. email) for access control checks.

Example

convex/teams.ts
// ❌ -- no checks! anyone can update any team if they get the ID
export const updateTeam = mutation({
args: {
id: v.id("teams"),
update: v.object({
name: v.optional(v.string()),
owner: v.optional(v.id("users")),
}),
},
handler: async (ctx, { id, update }) => {
await ctx.db.patch(id, update);
},
});

// ❌ -- checks access, but uses `email` which could be spoofed
export const updateTeam = mutation({
args: {
id: v.id("teams"),
update: v.object({
name: v.optional(v.string()),
owner: v.optional(v.id("users")),
}),
email: v.string(),
},
handler: async (ctx, { id, update, email }) => {
const teamMembers = /* load team members */
if (!teamMembers.some((m) => m.email === email)) {
throw new Error("Unauthorized");
}
await ctx.db.patch(id, update);
},
});

// ✅ -- checks access, and uses `ctx.auth`, which cannot be spoofed
export const updateTeam = mutation({
args: {
id: v.id("teams"),
update: v.object({
name: v.optional(v.string()),
owner: v.optional(v.id("users")),
}),
},
handler: async (ctx, { id, update }) => {
const user = await ctx.auth.getUserIdentity();
if (user === null) {
throw new Error("Unauthorized");
}
const isTeamMember = /* check if user is a member of the team */
if (!isTeamMember) {
throw new Error("Unauthorized");
}
await ctx.db.patch(id, update);
},
});

// ✅ -- separate functions which have different access control
export const setTeamOwner = mutation({
args: {
id: v.id("teams"),
owner: v.id("users"),
},
handler: async (ctx, { id, owner }) => {
const user = await ctx.auth.getUserIdentity();
if (user === null) {
throw new Error("Unauthorized");
}
const isTeamOwner = /* check if user is the owner of the team */
if (!isTeamOwner) {
throw new Error("Unauthorized");
}
await ctx.db.patch(id, { owner: owner });
},
});

export const setTeamName = mutation({
args: {
id: v.id("teams"),
name: v.string(),
},
handler: async (ctx, { id, name }) => {
const user = await ctx.auth.getUserIdentity();
if (user === null) {
throw new Error("Unauthorized");
}
const isTeamMember = /* check if user is a member of the team */
if (!isTeamMember) {
throw new Error("Unauthorized");
}
await ctx.db.patch(id, { name: name });
},
});

How?

Search for query, mutation, action, and httpAction in your Convex codebase, and ensure that all of them have some form of access control. Custom functions like authenticatedQuery can be helpful.

Some apps use Row Level Security (RLS) to check access to each document automatically whenever it's loaded, as described in this article. Alternatively, you can check access in each Convex function instead of checking access for each document.

Helper functions for common checks and common operations can also be useful -- e.g. isTeamMember, isTeamAdmin, loadTeam (which throws if the current user does not have access to the team).

Only schedule and ctx.run* internal functions

Why?

Public functions can be called by anyone, including potentially malicious attackers trying to break your app, and should be carefully audited to ensure they can't be used maliciously. Functions that are only called within Convex can be marked as internal, and relax these checks since Convex will ensure that internal functions can only be called within Convex.

How?

Search for ctx.runQuery, ctx.runMutation, and ctx.runAction in your Convex codebase. Also search for ctx.scheduler and check the crons.ts file. Ensure all of these use internal.foo.bar functions instead of api.foo.bar functions.

If you have code you want to share between a public Convex function and an internal Convex function, create a helper function that can be called from both. The public function will likely have additional access control checks.

Alternatively, make sure that api from _generated/api.ts is never used in your Convex functions directory.

Examples

convex/teams.ts
// ❌ -- using `api`
export const sendMessage = mutation({
args: {
body: v.string(),
author: v.string(),
},
handler: async (ctx, { body, author }) => {
// add message to the database
},
});

// crons.ts
crons.daily(
"send daily reminder",
{ hourUTC: 17, minuteUTC: 30 },
api.messages.sendMessage,
{ author: "System", body: "Share your daily update!" },
);

// ✅ Using `internal`
import { MutationCtx } from './_generated/server';
async function sendMessageHelper(
ctx: MutationCtx,
args: { body: string; author: string },
) {
// add message to the database
}

export const sendMessage = mutation({
args: {
body: v.string(),
},
handler: async (ctx, { body }) => {
const user = await ctx.auth.getUserIdentity();
if (user === null) {
throw new Error("Unauthorized");
}
await sendMessageHelper(ctx, { body, author: user.name ?? "Anonymous" });
},
});

export const sendInternalMessage = internalMutation({
args: {
body: v.string(),
// don't need to worry about `author` being spoofed since this is an internal function
author: v.string(),
},
handler: async (ctx, { body, author }) => {
await sendMessageHelper(ctx, { body, author });
},
});

// crons.ts
crons.daily(
"send daily reminder",
{ hourUTC: 17, minuteUTC: 30 },
internal.messages.sendInternalMessage,
{ author: "System", body: "Share your daily update!" },
);

Use helper functions to write shared code

Why?

Most logic should be written as plain TypeScript functions, with the query, mutation, and action wrapper functions being a thin wrapper around one or more helper function.

Concretely, most of your code should live in a directory like convex/model, and your public API, which is defined with query, mutation, and action, should have very short functions that mostly just call into convex/model.

Organizing your code this way makes several of the refactors mentioned in this list easier to do.

See the TypeScript page for useful types.

Example

This example overuses ctx.runQuery and ctx.runMutation, which is discussed more in the Avoid sequential ctx.runMutation / ctx.runQuery from actions section.

convex/users.ts
export const getCurrentUser = query({
args: {},
handler: async (ctx) => {
const userIdentity = await ctx.auth.getUserIdentity();
if (userIdentity === null) {
throw new Error("Unauthorized");
}
const user = /* query ctx.db to load the user */
const userSettings = /* load other documents related to the user */
return { user, settings: userSettings };
},
});
convex/conversations.ts
export const listMessages = query({
args: {
conversationId: v.id("conversations"),
},
handler: async (ctx, { conversationId }) => {
const user = await ctx.runQuery(api.users.getCurrentUser);
const conversation = await ctx.db.get(conversationId);
if (conversation === null || !conversation.members.includes(user._id)) {
throw new Error("Unauthorized");
}
const messages = /* query ctx.db to load the messages */
return messages;
},
});

export const summarizeConversation = action({
args: {
conversationId: v.id("conversations"),
},
handler: async (ctx, { conversationId }) => {
const messages = await ctx.runQuery(api.conversations.listMessages, {
conversationId,
});
const summary = /* call some external service to summarize the conversation */
await ctx.runMutation(api.conversations.addSummary, {
conversationId,
summary,
});
},
});

Most of the code here is now in the convex/model directory. The API for this application is in convex/conversations.ts, which contains very little code itself.

convex/model/users.ts
import { QueryCtx } from '../_generated/server';

export async function getCurrentUser(ctx: QueryCtx) {
const userIdentity = await ctx.auth.getUserIdentity();
if (userIdentity === null) {
throw new Error("Unauthorized");
}
const user = /* query ctx.db to load the user */
const userSettings = /* load other documents related to the user */
return { user, settings: userSettings };
}
convex/model/conversations.ts
import { QueryCtx, MutationCtx } from '../_generated/server';
import * as Users from './users';

export async function ensureHasAccess(
ctx: QueryCtx,
{ conversationId }: { conversationId: Id<"conversations"> },
) {
const user = await Users.getCurrentUser(ctx);
const conversation = await ctx.db.get(conversationId);
if (conversation === null || !conversation.members.includes(user._id)) {
throw new Error("Unauthorized");
}
return conversation;
}

export async function listMessages(
ctx: QueryCtx,
{ conversationId }: { conversationId: Id<"conversations"> },
) {
await ensureHasAccess(ctx, { conversationId });
const messages = /* query ctx.db to load the messages */
return messages;
}

export async function addSummary(
ctx: MutationCtx,
{
conversationId,
summary,
}: { conversationId: Id<"conversations">; summary: string },
) {
await ensureHasAccess(ctx, { conversationId });
await ctx.db.patch(conversationId, { summary });
}

export async function generateSummary(
messages: Doc<"messages">[],
conversationId: Id<"conversations">,
) {
const summary = /* call some external service to summarize the conversation */
return summary;
}
convex/conversations.ts
import * as Conversations from './model/conversations';

export const addSummary = internalMutation({
args: {
conversationId: v.id("conversations"),
summary: v.string(),
},
handler: async (ctx, { conversationId, summary }) => {
await Conversations.addSummary(ctx, { conversationId, summary });
},
});

export const listMessages = internalQuery({
args: {
conversationId: v.id("conversations"),
},
handler: async (ctx, { conversationId }) => {
return Conversations.listMessages(ctx, { conversationId });
},
});

export const summarizeConversation = action({
args: {
conversationId: v.id("conversations"),
},
handler: async (ctx, { conversationId }) => {
const messages = await ctx.runQuery(internal.conversations.listMessages, {
conversationId,
});
const summary = await Conversations.generateSummary(
messages,
conversationId,
);
await ctx.runMutation(internal.conversations.addSummary, {
conversationId,
summary,
});
},
});

Use runAction only when using a different runtime

Why?

Calling runAction has more overhead than calling a plain TypeScript function. It counts as an extra function call with its own memory and CPU usage, while the parent action is doing nothing except waiting for the result. Therefore, runAction should almost always be replaced with calling a plain TypeScript function. However, if you want to call code that requires Node.js from a function in the Convex runtime (e.g. using a library that requires Node.js), then you can use runAction to call the Node.js code.

Example

convex/scrape.ts
// ❌ -- using `runAction`
export const scrapeWebsite = action({
args: {
siteMapUrl: v.string(),
},
handler: async (ctx, { siteMapUrl }) => {
const siteMap = await fetch(siteMapUrl);
const pages = /* parse the site map */
await Promise.all(
pages.map((page) =>
ctx.runAction(internal.scrape.scrapeSinglePage, { url: page }),
),
);
},
});
convex/model/scrape.ts
import { ActionCtx } from '../_generated/server';

// ✅ -- using a plain TypeScript function
export async function scrapeSinglePage(
ctx: ActionCtx,
{ url }: { url: string },
) {
const page = await fetch(url);
const text = /* parse the page */
await ctx.runMutation(internal.scrape.addPage, { url, text });
}
convex/scrape.ts
import * as Scrape from './model/scrape';

export const scrapeWebsite = action({
args: {
siteMapUrl: v.string(),
},
handler: async (ctx, { siteMapUrl }) => {
const siteMap = await fetch(siteMapUrl);
const pages = /* parse the site map */
await Promise.all(
pages.map((page) => Scrape.scrapeSinglePage(ctx, { url: page })),
);
},
});

How?

Search for runAction in your Convex codebase, and see if the function it calls uses the same runtime as the parent function. If so, replace the runAction with a plain TypeScript function. You may want to structure your functions so the Node.js functions are in a separate directory so it's easier to spot these.

Avoid sequential ctx.runMutation / ctx.runQuery calls from actions

Why?

Each ctx.runMutation or ctx.runQuery runs in its own transaction, which means if they're called separately, they may not be consistent with each other. If instead we call a single ctx.runQuery or ctx.runMutation, we're guaranteed that the results we get are consistent.

How?

Audit your calls to ctx.runQuery and ctx.runMutation in actions. If you see multiple in a row with no other code between them, replace them with a single ctx.runQuery or ctx.runMutation that handles both things. Refactoring your code to use helper functions will make this easier.

Example

convex/teams.ts
// ❌ -- this assertion could fail if the team changed between running the two queries
const team = await ctx.runQuery(internal.teams.getTeam, { teamId });
const teamOwner = await ctx.runQuery(internal.teams.getTeamOwner, { teamId });
assert(team.owner === teamOwner._id);
convex/teams.ts
import * as Teams from './model/teams';
import * as Users from './model/users';

export const sendBillingReminder = action({
args: {
teamId: v.id("teams"),
},
handler: async (ctx, { teamId }) => {
// ✅ -- this will always pass
const teamAndOwner = await ctx.runQuery(internal.teams.getTeamAndOwner, {
teamId,
});
assert(teamAndOwner.team.owner === teamAndOwner.owner._id);
// send a billing reminder email to the owner
},
});

export const getTeamAndOwner = internalQuery({
args: {
teamId: v.id("teams"),
},
handler: async (ctx, { teamId }) => {
const team = await Teams.load(ctx, { teamId });
const owner = await Users.load(ctx, { userId: team.owner });
return { team, owner };
},
});

Exceptions

If you're intentionally trying to process more data than fits in a single transaction, like running a migration or aggregating data, then it makes sense to have multiple sequential ctx.runMutation / ctx.runQuery calls.

Multiple ctx.runQuery / ctx.runMutation calls are often necessary because the action does a side effect in between them. For example, reading some data, feeding it to an external service, and then writing the result back to the database.

Use ctx.runQuery and ctx.runMutation sparingly in queries and mutations

Why?

While these queries and mutations run in the same transaction, and will give consistent results, they have extra overhead compared to plain TypeScript functions. Wanting a TypeScript helper function is much more common than needing ctx.runQuery or ctx.runMutation.

How?

Audit your calls to ctx.runQuery and ctx.runMutation in queries and mutations. Unless one of the exceptions below applies, replace them with a plain TypeScript function.

Exceptions

  • If you're using components, these require ctx.runQuery or ctx.runMutation.
  • If you want partial rollback on an error, you will want ctx.runMutation instead of a plain TypeScript function.
convex/messages.ts
export const trySendMessage = mutation({
args: {
body: v.string(),
author: v.string(),
},
handler: async (ctx, { body, author }) => {
try {
await ctx.runMutation(internal.messages.sendMessage, { body, author });
} catch (e) {
// Record the failure, but rollback any writes from `sendMessage`
await ctx.db.insert("failures", {
kind: "MessageFailed",
body,
author,
error: `Error: ${e}`,
});
}
},
});