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");
});