StarterApp Docs
Database

Real-Time Sync

Reactive data synchronization with Convex

Convex provides real-time data synchronization through reactive queries. When data changes in the database, all connected components update automatically without polling or manual refreshes. This guide shows the actual implementation patterns used in production.

Real-Time by Default

Components using useQuery automatically subscribe to data changes and re-render when mutations modify the queried data.

Reactive Query Pattern

The foundation of real-time sync is the reactive query system. Here's how it works in practice:

React Component

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

export default function SupportTickets() {
  // Automatically re-renders when data changes
  const tickets = useQuery(api.support.getUserSupportTickets);

  if (!tickets) return <div>Loading...</div>;

  return (
    <div>
      {tickets.map((ticket) => (
        <TicketCard key={ticket._id} {...ticket} />
      ))}
    </div>
  );
}

Convex Query

import { userQuery } from "./_helpers/builders";
import { T } from "./tables";

export const getUserSupportTickets = userQuery({
  args: {},
  handler: async (ctx) => {
    // ctx.viewerId is auto-injected by userQuery builder
    // ctx.db automatically scopes to current user
    return await ctx.db
      .query(T.supportTickets)
      .order("desc") // Most recent first
      .collect();
  },
});

How It Works

Components using useQuery automatically subscribe to data changes. When any mutation modifies the queried data, Convex pushes updates to all subscribers instantly.

How Synchronization Works

Convex tracks data dependencies and pushes updates to subscribers:

Client Subscribes

const tickets = useQuery(api.support.getUserSupportTickets);

The client establishes a WebSocket connection and subscribes to the query.

Mutation Modifies Data

export const createSupportTicket = userMutation({
  args: {
    title: v.string(),
    description: v.string(),
  },
  handler: async (ctx, { title, description }) => {
    const userId = ctx.viewerId as UserId;

    await assertLimitAction(ctx, {
      scope: "support:create",
      viewerId: userId,
      max: 10,
      windowMs: 60_000,
    });

    return await ctx.db.insert(T.supportTickets, {
      userId,
      title,
      description,
      status: "Open",
      updatedAt: Date.now(),
    });
  },
});

Convex Detects Changes

The mutation modifies the supportTickets table, triggering dependency tracking.

Updates Pushed to Clients

All components querying supportTickets receive the update and re-render automatically.

Real-World Examples

Support Ticket System

The actual implementation from /convex/support.ts:

Advanced Patterns

Filtered Queries with Indexes

Using compound indexes for efficient filtered queries:

export const getTicketsByStatus = userQuery({
  args: {
    status: v.union(
      v.literal("Open"),
      v.literal("In Progress"),
      v.literal("Resolved")
    ),
  },
  handler: async (ctx, { status }) => {
    // Uses compound index: by_user_status
    return await ctx.db
      .query(T.supportTickets)
      .withIndex("by_user_status", (q) =>
        q.eq("userId", ctx.viewerId).eq("status", status)
      )
      .order("desc")
      .collect();
  },
});

Index Required

This query requires a compound index by_user_status on ["userId", "status"] in your schema.

Optimistic Updates

Improve perceived performance with optimistic updates:

"use client";

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

export function TicketStatus({ ticket }) {
  const updateStatus = useMutation(api.support.updateSupportTicketStatus);
  const [optimisticStatus, setOptimisticStatus] = useState<string | null>(null);

  async function handleStatusChange(newStatus: string) {
    // Show update immediately
    setOptimisticStatus(newStatus);

    try {
      await updateStatus({
        ticketId: ticket._id,
        status: newStatus,
      });
    } catch (error) {
      // Revert on error
      setOptimisticStatus(null);
      console.error("Failed to update status:", error);
    }
  }

  const displayStatus = optimisticStatus ?? ticket.status;

  return (
    <select
      value={displayStatus}
      onChange={(e) => handleStatusChange(e.target.value)}
    >
      <option value="Open">Open</option>
      <option value="In Progress">In Progress</option>
      <option value="Resolved">Resolved</option>
    </select>
  );
}

Best Practice

Always handle errors in optimistic updates and revert to server state on failure.

Paginated Real-Time Queries

Handle large datasets with pagination while maintaining real-time updates:

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

Client implementation:

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

export function TicketList() {
  const { results, status, loadMore } = usePaginatedQuery(
    api.support.paginatedTickets,
    {},
    { initialNumItems: 20 }
  );

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

Rate Limiting Integration

The actual rate limiting implementation from /convex/rateLimits.ts:

export const bump = mutation({
  args: {
    key: v.string(),
    bucket: v.number(),
    max: v.number(),
    ttlMs: v.optional(v.number()),
  },
  handler: async (ctx, { key, bucket, max, ttlMs }) => {
    const expiresAt = Date.now() + (ttlMs ?? 60_000);

    // Try to create new bucket row
    try {
      await ctx.db.insert("rateLimits", { key, bucket, count: 1, expiresAt });
      return { allowed: true, remaining: max - 1 };
    } catch {
      /* unique insert race, continue */
    }

    // Retry-safe patch loop
    for (let i = 0; i < 3; i++) {
      const doc = await ctx.db
        .query("rateLimits")
        .withIndex("by_key_bucket", (q) =>
          q.eq("key", key).eq("bucket", bucket)
        )
        .unique();

      if (doc) {
        const next = doc.count + 1;
        if (next > max) {
          return {
            allowed: false,
            retryAfterMs: Math.max(0, doc.expiresAt - Date.now()),
          };
        }
        try {
          await ctx.db.patch(doc._id, { count: next });
          return { allowed: true, remaining: max - next };
        } catch {
          /* write conflict */
        }
      }
    }
    return { allowed: false, retryAfterMs: ttlMs ?? 60_000 };
  },
});

Usage with assertions:

export async function assertLimitAction(
  ctx: { runMutation: (mutation: any, args: any) => Promise<any> },
  options: {
    scope: string;
    viewerId: string;
    max?: number;
    windowMs?: number;
  }
) {
  const { scope, viewerId, max = 10, windowMs = 60_000 } = options;
  const bucket = Math.floor(Date.now() / windowMs);
  const rl = await ctx.runMutation(api.rateLimits.bump, {
    key: `${scope}:${viewerId}`,
    bucket,
    max,
    ttlMs: windowMs,
  });
  if (!rl.allowed) {
    throw new AppError("RATE_LIMITED", "Too many requests", {
      retryAfterMs: rl.retryAfterMs,
    });
  }
}

Durable Rate Limiting

This implementation uses database-backed buckets for durable rate limiting that survives server restarts and works across distributed deployments.

Presence Tracking

Implement real-time presence using the same patterns:

Schema Definition

presence: defineTable({
  userId: v.id("user"),
  sessionId: v.string(),
  lastSeen: v.number(),
  status: v.union(
    v.literal("online"),
    v.literal("away"),
    v.literal("offline")
  ),
})
  .index("by_user_id", ["userId"])
  .index("by_last_seen", ["lastSeen"])

Update Presence

export const updatePresence = userMutation({
  args: {
    status: v.union(
      v.literal("online"),
      v.literal("away"),
      v.literal("offline")
    ),
  },
  handler: async (ctx, { status }) => {
    const userId = ctx.viewerId as UserId;

    const existing = await ctx.db
      .query(T.presence)
      .withIndex("by_user_id", (q) => q.eq("userId", userId))
      .unique();

    if (existing) {
      await ctx.db.patch(existing._id, {
        lastSeen: Date.now(),
        status,
      });
    } else {
      await ctx.db.insert(T.presence, {
        userId,
        sessionId: crypto.randomUUID(),
        lastSeen: Date.now(),
        status,
      });
    }
  },
});

Query Active Users

export const getActiveUsers = userQuery({
  args: {},
  handler: async (ctx) => {
    const cutoff = Date.now() - 5 * 60 * 1000; // 5 minutes

    return await ctx.db
      .query(T.presence)
      .withIndex("by_last_seen", (q) => q.gt("lastSeen", cutoff))
      .filter((q) => q.neq(q.field("status"), "offline"))
      .collect();
  },
});

Builder Pattern Benefits

The userQuery and userMutation builders from /convex/_helpers/builders.ts provide:

Auto-Authentication

ctx.viewerId is automatically injected and verified

Auto-Scoping

Database queries automatically filter by current user

Rate Limiting

Built-in support for rate limit assertions

Billing Integration

Autumn billing checks via ctx.check() and ctx.track()

Error Handling in Real-Time

Handle errors gracefully with typed error codes:

"use client";

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

export function CreateTicket() {
  const createTicket = useMutation(api.support.createSupportTicket);
  const [error, setError] = useState<string | null>(null);

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);

    try {
      await createTicket({
        title: formData.get("title") as string,
        description: formData.get("description") as string,
      });

      setError(null);
      e.currentTarget.reset();
    } catch (err: any) {
      if (err.data?.code === "RATE_LIMITED") {
        const retryAfter = Math.ceil(err.data.retryAfterMs / 1000);
        setError(`Rate limited. Try again in ${retryAfter} seconds.`);
      } else if (err.data?.code === "UNAUTHENTICATED") {
        setError("Please sign in to create tickets.");
      } else {
        setError("Failed to create ticket. Please try again.");
      }
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      {error && <div className="error">{error}</div>}
      <input name="title" required />
      <textarea name="description" required />
      <button type="submit">Create Ticket</button>
    </form>
  );
}

Performance Best Practices

Testing Real-Time Sync

Test reactive queries with Convex test utilities:

import { convexTest } from "convex-test";
import { expect, test } from "vitest";
import schema from "../schema";
import { api } from "../_generated/api";

test("ticket creation triggers real-time update", async () => {
  const t = convexTest(schema);

  // Mock authenticated user
  t.setAuth({ subject: "user123" });

  // Create ticket
  const ticketId = await t.mutation(api.support.createSupportTicket, {
    title: "Test Ticket",
    description: "Test Description",
  });

  // Query should include new ticket
  const tickets = await t.query(api.support.getUserSupportTickets, {});
  expect(tickets).toHaveLength(1);
  expect(tickets[0]._id).toBe(ticketId);
  expect(tickets[0].title).toBe("Test Ticket");
});