Practice Questions

1. You are designing a Convex schema for a blogging platform. Each post belongs to an author (stored in a separate table). Define a schema with two related tables, where each post references its author by ID. Include at least two fields per table (besides the relationship).

Solution
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

export default defineSchema({
  authors: defineTable({
    name: v.string(),
    email: v.string(),
  }),
  posts: defineTable({
    authorId: v.id("authors"),
    title: v.string(),
    body: v.string(),
    publishedAt: v.optional(v.number()),
  }),
});

The v.id("authors") validator creates a typed reference from the posts table to the authors table. Convex does not enforce foreign-key constraints at the database level (it will not prevent you from inserting a post with a non-existent authorId), but the validator ensures the value is a syntactically valid ID for the authors table. It is up to your application logic to ensure the referenced document exists.

2. Explain the difference between ctx.db.get(id) and ctx.db.query("table").collect() in Convex. When would you use each, and what are the performance implications?

Solution
  • ctx.db.get(id) fetches a single document by its _id. It is the fastest way to read a specific document because it performs a direct lookup. It does not scan or filter. Use it when you already know the document’s ID (e.g., from a route parameter or a reference field).

  • ctx.db.query("table").collect() scans the entire table and returns all documents as an array. Use it when you need a full list (for example, displaying all items on a page). However, it reads every document in the table, so it becomes slow and expensive as the table grows.

In general, prefer ctx.db.get() for single-document lookups, and use .query() with .withIndex() or .paginate() when you need to fetch multiple documents efficiently.

3. A student writes the following Convex query to fetch all comments for a specific blog post:

export const listByPost = query({
  args: { postId: v.id("posts") },
  handler: async (ctx, args) => {
    const allComments = await ctx.db.query("comments").collect();
    return allComments.filter((c) => c.postId === args.postId);
  },
});

What is wrong with this approach? Rewrite the query to fix the problem, assuming no index exists yet. Then, define an index and rewrite the query again to use it.

Solution

The problem is that the query fetches all comments from the database and filters them on the server. As the table grows, this reads far more data than necessary (every comment for every post) even though only a small subset is needed.

Fix 1 — server-side .filter() (no index):

export const listByPost = query({
  args: { postId: v.id("posts") },
  handler: async (ctx, args) => {
    return await ctx.db
      .query("comments")
      .filter((q) => q.eq(q.field("postId"), args.postId))
      .collect();
  },
});

This is better because Convex applies the filter on the backend during the scan rather than transferring all documents. However, it still performs a full table scan.

Fix 2 — define an index and use .withIndex():

In the schema:

comments: defineTable({
  postId: v.id("posts"),
  body: v.string(),
}).index("by_post", ["postId"]),

In the query:

export const listByPost = query({
  args: { postId: v.id("posts") },
  handler: async (ctx, args) => {
    return await ctx.db
      .query("comments")
      .withIndex("by_post", (q) => q.eq("postId", args.postId))
      .collect();
  },
});

With the index, Convex jumps directly to the matching documents, so there is no full table scan. This is the most efficient of the three versions.

4. What is a database index? Explain why indexes improve read performance and what trade-off they introduce for write operations. Give a real-world analogy.

Solution

A database index is a data structure that lets the database quickly locate documents matching a specific field value without scanning every document in the table.

Read performance: Without an index, finding all documents where status === "active" requires reading every document in the table (a full table scan). With an index on the status field, the database maintains a sorted lookup structure that can jump directly to matching documents, similar to how an index in the back of a textbook lets you look up a topic by page number instead of reading every page.

Write trade-off: Every time a document is inserted, updated, or deleted, the database must also update all relevant indexes. This adds a small amount of overhead to each write. Indexes also take up storage space. For tables with heavy write traffic and rarely-used query patterns, an unnecessary index wastes resources.

Analogy: Think of a library card catalog. It makes finding a book fast (read), but every time a new book is added, the librarian must also file a new card in the catalog (write overhead). Having more catalogs (by author, by subject, by year) speeds up more kinds of searches but requires more filing work and more cabinet space.

5. Explain the difference between a hard delete and a soft delete. What are the advantages of soft deletes? Describe how you would implement a soft delete pattern in a Convex schema and query.

Solution
  • Hard delete: The document is permanently removed from the database with ctx.db.delete(id). Once deleted, it cannot be recovered.

  • Soft delete: The document remains in the database but is marked as deleted, typically by setting a timestamp field (e.g., deletedAt). Queries filter out soft-deleted documents, so they appear gone to the user, but the data is still there.

Advantages of soft deletes:

  1. Recoverability — deleted data can be restored if needed (undo, audit trail).
  2. Instant UI feedback — marking a document as deleted is a single field update, which is fast, but hard-deleting a parent and all its children may take longer.
  3. Background cleanup — actual deletion of related data can happen asynchronously via cron jobs or scheduled functions, avoiding mutation size limits.

Implementation in Convex:

Schema:

projects: defineTable({
  name: v.string(),
  deletedAt: v.optional(v.number()),
}).index("by_deletedAt", ["deletedAt"]),

Soft-delete mutation:

export const remove = mutation({
  args: { id: v.id("projects") },
  handler: async (ctx, args) => {
    await ctx.db.patch(args.id, { deletedAt: Date.now() });
  },
});

Query (exclude soft-deleted):

export const list = query({
  handler: async (ctx) => {
    return await ctx.db
      .query("projects")
      .withIndex("by_deletedAt", (q) => q.eq("deletedAt", undefined))
      .collect();
  },
});

6. What is an internal function in Convex, and how does it differ from a regular query or mutation? Why would you use one? Write a short example of an internal mutation and show how another mutation could schedule it.

Solution

An internal function is a Convex function that can only be called from other server-side functions, not from the client. You define it using internalMutation or internalQuery instead of mutation or query.

Why use internal functions:

  1. Security — they cannot be invoked from the client, so they are safe for privileged operations (e.g., bulk deletes, admin-only logic).
  2. Scheduling — the Convex scheduler (ctx.scheduler.runAfter) can only call internal functions.
  3. Separation of concerns — they let you break complex backend logic into smaller, composable pieces.

Example:

import { internalMutation, mutation } from "./_generated/server";
import { internal } from "./_generated/api";
import { v } from "convex/values";

// Internal mutation — server-only
export const deleteTasksByProject = internalMutation({
  args: { projectId: v.id("projects") },
  handler: async (ctx, args) => {
    const tasks = await ctx.db
      .query("tasks")
      .withIndex("by_project", (q) => q.eq("projectId", args.projectId))
      .collect();
    for (const task of tasks) {
      await ctx.db.delete(task._id);
    }
  },
});

// Public mutation that schedules the internal one
export const remove = mutation({
  args: { id: v.id("projects") },
  handler: async (ctx, args) => {
    await ctx.db.patch(args.id, { deletedAt: Date.now() });
    await ctx.scheduler.runAfter(0, internal.tasks.deleteTasksByProject, {
      projectId: args.id,
    });
  },
});

The ctx.scheduler.runAfter(delay, fn, args) schedules the internal function to run after the specified delay (in milliseconds). Using 0 means “as soon as possible, in a separate transaction.”

7. A Convex mutation that deletes a parent document and all its children in a single transaction starts failing when the number of children exceeds a few thousand. Explain why this happens and describe two strategies for handling it.

Solution

Convex mutations have execution limits: they can only read/write a bounded number of documents in a single transaction. When a parent has thousands of children, deleting them all in one mutation exceeds these limits, causing the mutation to fail.

Strategy 1 — Soft delete + scheduled cleanup:

Instead of hard-deleting the parent immediately, soft-delete it (set a deletedAt timestamp). Then schedule an internal mutation to delete children in batches. A cron job can periodically find soft-deleted parents and schedule cleanup for each.

Strategy 2 — Batched scheduled mutations:

The parent mutation schedules an internal function with ctx.scheduler.runAfter() to delete a batch of children. That internal function deletes a fixed number of children (e.g., 100), and if more remain, schedules itself again. Each batch runs in its own transaction, staying within limits.

// Pseudocode for batched deletion
export const deleteBatch = internalMutation({
  args: { projectId: v.id("projects") },
  handler: async (ctx, args) => {
    const batch = await ctx.db
      .query("tasks")
      .withIndex("by_project", (q) => q.eq("projectId", args.projectId))
      .take(100);

    for (const task of batch) {
      await ctx.db.delete(task._id);
    }

    if (batch.length === 100) {
      // More remain — schedule another batch
      await ctx.scheduler.runAfter(0, internal.tasks.deleteBatch, {
        projectId: args.projectId,
      });
    }
  },
});

Both strategies avoid hitting transaction limits by spreading the work across multiple transactions.

8. Explain how cursor-based pagination works in Convex. What does the server return after each page, and how does the client use it to load the next batch? Write a Convex query that paginates a "products" table in descending order, and show the React code that consumes it with a “Load More” button.

Solution

In cursor-based pagination, each page of results comes with a cursor, an opaque value that marks where the current page ended. To fetch the next page, the client sends that cursor back to the server, and the server resumes from that exact position.

The server returns an object with three fields:

  • page — the array of documents for this batch
  • isDone — whether there are more results after this page
  • continueCursor — the cursor to pass in the next request

Query side — use paginationOptsValidator and .paginate():

import { query } from "./_generated/server";
import { paginationOptsValidator } from "convex/server";

export const list = query({
  args: { paginationOpts: paginationOptsValidator },
  handler: async (ctx, args) => {
    return await ctx.db
      .query("products")
      .order("desc")
      .paginate(args.paginationOpts);
  },
});

Client side — use the usePaginatedQuery hook:

import { usePaginatedQuery } from "convex/react";
import { api } from "../convex/_generated/api";

function ProductList() {
  const { results, status, loadMore } = usePaginatedQuery(
    api.products.list,
    {},
    { initialNumItems: 10 },
  );

  return (
    <div>
      {results.map((product) => (
        <div key={product._id}>{product.name}</div>
      ))}
      {status === "CanLoadMore" && (
        <button onClick={() => loadMore(10)}>Load More</button>
      )}
    </div>
  );
}

The usePaginatedQuery hook manages cursors internally. It returns status which is one of "LoadingFirstPage", "CanLoadMore", or "Exhausted". The loadMore(n) function fetches the next n items using the cursor from the previous batch. The results array accumulates all loaded documents across all pages.

9. Explain what a full-text search index is and how it differs from a regular database index. When would you use each? Write a Convex schema snippet that defines a search index on a name field and a filter field for deletedAt.

Solution

A regular index is designed for exact-value lookups and range queries. It works well when you need to find documents where a field equals a specific value (e.g., projectId === "abc123"). It does not support partial matching or word-based search.

A full-text search index tokenizes text by breaking it into individual words, and it supports prefix matching and relevance-based results. You use it when a user types a search query and expects to find documents that contain those words (e.g., “search for projects whose name contains ‘marketing’”).

When to use each:

  • Regular index: filtering by ID, status, category, or any exact value.
  • Search index: user-facing search bars, finding documents by keyword or partial text.

Schema snippet:

projects: defineTable({
  name: v.string(),
  description: v.optional(v.string()),
  deletedAt: v.optional(v.number()),
})
  .index("by_deletedAt", ["deletedAt"])
  .searchIndex("search_name", {
    searchField: "name",
    filterFields: ["deletedAt"],
  }),

Query using the search index:

export const search = query({
  args: { query: v.string() },
  handler: async (ctx, args) => {
    return await ctx.db
      .query("projects")
      .withSearchIndex("search_name", (q) =>
        q.search("name", args.query).eq("deletedAt", undefined),
      )
      .collect();
  },
});

The filterFields in the search index definition allow you to combine text search with equality filters. Here, we exclude soft-deleted projects from search results.

10. What is a cron job in the context of a backend application? Describe a scenario (not related to the Project Planner) where a cron job would be useful. Then, write a Convex cron definition that runs an internal mutation called notifications.sendDigest every day at 9:00 AM UTC.

Solution

A cron job is a scheduled task that runs automatically at a fixed interval or at specific times. The name comes from the Unix cron daemon, which executes commands on a schedule. In a backend context, cron jobs handle recurring maintenance or background work that does not need to be triggered by a user action.

Scenario: An e-commerce platform needs to check for abandoned shopping carts every hour. If a cart has been inactive for more than 24 hours, the system sends a reminder email to the user. A cron job that runs hourly can query for stale carts and trigger the email notifications.

Convex cron definition:

import { cronJobs } from "convex/server";
import { internal } from "./_generated/api";

const crons = cronJobs();

crons.daily(
  "send daily digest",
  { hourUTC: 9, minuteUTC: 0 },
  internal.notifications.sendDigest,
);

export default crons;

This defines a cron job named "send daily digest" that runs once per day at 09:00 UTC and calls the internal mutation notifications.sendDigest. A cron job can only call an internal function, so a client cannot trigger it.

11. You are building an app where users can create workspaces, and each workspace contains documents. A user deletes a workspace that has 5,000 documents. Design the deletion flow end-to-end: what happens when the user clicks “Delete,” what the mutation does, and how the documents are eventually cleaned up. Justify your choices.

Solution

Step 1 — User clicks “Delete”:

A confirmation dialog appears (since this is a destructive action). Upon confirmation, the client calls a workspaces.remove mutation.

Step 2 — The mutation soft-deletes the workspace:

export const remove = mutation({
  args: { id: v.id("workspaces") },
  handler: async (ctx, args) => {
    await ctx.db.patch(args.id, { deletedAt: Date.now() });
  },
});

This is instant. The workspace disappears from queries that filter by deletedAt === undefined. No documents are touched yet.

Step 3 — A cron job handles cleanup:

A daily cron job finds soft-deleted workspaces and schedules an internal mutation to delete their documents:

crons.daily(
  "cleanup deleted workspaces",
  { hourUTC: 3, minuteUTC: 0 },
  internal.workspaces.cleanupDeleted,
);

The cleanupDeleted internal mutation queries for soft-deleted workspaces and schedules a batched delete for each.

Step 4 — Batched document deletion:

An internal mutation deletes documents in batches (e.g., 100 at a time), scheduling itself again if more remain. Once all documents are gone, the workspace document itself is hard-deleted.

Justification:

  • Soft delete gives instant UI feedback and avoids mutation size limits.
  • Cron + scheduler moves heavy work to the background, outside the user’s request path.
  • Batched deletion respects transaction limits. 5,000 deletes in one transaction would fail.
  • Internal functions ensure cleanup logic cannot be triggered by a client.