Real-time Subscriptions

Convex provides automatic real-time updates with zero configuration. When data changes in the database, every client subscribed to an affected query receives fresh results immediately.

How reactivity works

  1. The client calls useQuery(api.tasks.list) and receives initial results.
  2. Convex tracks which tables and indexes the query read.
  3. When a mutation modifies any of those tables, Convex re-executes the query server-side.
  4. If the result differs from the previous run, the new data is pushed to every subscribed client.
  5. React re-renders the component with the updated data.

There is no polling, no WebSocket management, and no cache invalidation logic to write.

Using the useQuery hook

"use client";

import { useQuery } from "convex/react";
import { api } from "@/convex/_generated/api";

export function TaskList({ projectId }: { projectId: string }) {
  const tasks = useQuery(api.tasks.listByProject, { projectId });

  if (tasks === undefined) {
    return <p>Loading...</p>;
  }

  return (
    <ul>
      {tasks.map((task) => (
        <li key={task._id}>{task.title}</li>
      ))}
    </ul>
  );
}
  • useQuery returns undefined while the first result is loading.
  • Once loaded, it returns the query result and keeps it up to date automatically.
  • Pass "skip" as the second argument to conditionally disable the subscription.

Optimistic updates

Mutations can define optimistic updates so the UI reflects changes before the server confirms them.

const createTask = useMutation(api.tasks.create).withOptimisticUpdate(
  (localStore, args) => {
    const current = localStore.getQuery(api.tasks.listByProject, {
      projectId: args.projectId,
    });
    if (current !== undefined) {
      localStore.setQuery(api.tasks.listByProject, { projectId: args.projectId }, [
        ...current,
        { _id: crypto.randomUUID(), ...args, tags: [], metadata: {} },
      ]);
    }
  }
);

Subscription patterns

PatternWhen to use
Single queryDisplaying a list or detail view
Conditional queryOnly subscribe when a value is available (useQuery(fn, id ? { id } : "skip"))
Dependent queriesChain queries where one provides args for the next
Paginated queryUse usePaginatedQuery for infinite scroll or page-based lists

Tips

  • Avoid broad queries that read entire tables — use indexes to limit the scope.
  • Keep query functions fast; they re-run on every relevant mutation.
  • Use usePaginatedQuery for large datasets to limit the amount of data transferred.