Realtime + Pagination

Doc Status: Good | ✓ Clear summary | ✓ Easy to read | ✓ Matches code | ✓ Good structure | ✓ Professional look | ✓ Visual components

Realtime via useQuery

useQuery creates a persistent WebSocket subscription — when the underlying data changes, the component re-renders automatically.
'use client';
import { useQuery } from 'convex/react';
import { api } from '@packages/backend/convex/_generated/api';

// Automatically re-renders when data changes
const student = useQuery(api.students.get, { id: studentId });
No manual polling, no cache invalidation, no WebSocket management.

Pagination

Use usePaginatedQuery for cursor-based pagination:
// packages/backend/convex/students.ts
export const listByCenter = query({
  args: {
    centerId: v.id('centers'),
    paginationOpts: paginationOptsValidator,
  },
  handler: async (ctx, { centerId, paginationOpts }) => {
    return await ctx.db
      .query('students')
      .withIndex('by_center_status', (q) => q.eq('centerId', centerId))
      .order('desc')
      .paginate(paginationOpts);
  },
});
// Client component
'use client';
import { usePaginatedQuery } from 'convex/react';

function StudentList({ centerId }: { centerId: Id<'centers'> }) {
  const { results, status, loadMore } = usePaginatedQuery(
    api.students.listByCenter,
    { centerId },
    { initialNumItems: 20 }
  );

  if (status === 'LoadingFirstPage') return <Skeleton />;

  return (
    <>
      {results.map((s) => <StudentRow key={s._id} student={s} />)}
      {status === 'CanLoadMore' && (
        <button onClick={() => loadMore(20)}>Load more</button>
      )}
    </>
  );
}

Status Values

StatusMeaning
LoadingFirstPageInitial load in progress
LoadMoreLoading additional pages
CanLoadMoreMore results available, call loadMore
ExhaustedAll results loaded

WebRTC Signaling (Piano Mirror)

Convex subscriptions serve as a WebRTC signaling channel for P2P features. Peer A inserts offer/answer/ICE messages; peer B subscribes via useQuery and receives them in realtime.

How It Works

  1. Peer A calls rooms.create or rooms.join → gets a roomId
  2. Peer A and B share the room slug (e.g., “ABC123”)
  3. Peer A calls signaling.sendMessage with an offer
  4. Peer B’s useQuery(api.signaling.inboxFor, { roomId, since }) receives it
  5. Peer B responds with an answer via signaling.sendMessage
  6. Both peers use the signaling exchange to establish a direct WebRTC connection

Rooms

// packages/backend/convex/rooms.ts
export const create = mutation({
  args: {
    slug: v.string(), // e.g. "ABC123" — shared between peers
    kind: v.optional(v.union(v.literal('one-on-one'), v.literal('class'))),
    displayName: v.optional(v.string()),
  },
  handler: async (ctx, { slug, kind, displayName }) => {
    // Creates room + inserts host as first roomPeer
  },
});

export const join = mutation({
  args: { slug: v.string(), displayName: v.optional(v.string()) },
  handler: async (ctx, { slug, displayName }) => {
    // Joins existing open room by slug
  },
});

export const peers = query({
  args: { roomId: v.id('rooms') },
  handler: async (ctx, { roomId }) => {
    // Returns joined peers in the room
  },
});

Signaling

// packages/backend/convex/signaling.ts
export const sendMessage = mutation({
  args: {
    roomId: v.id('rooms'),
    toUserId: v.id('users'),
    type: v.union(v.literal('offer'), v.literal('answer'), v.literal('ice')),
    payload: v.any(), // SDP string for offer/answer; RTCIceCandidateInit for ice
  },
  handler: async (ctx, { roomId, toUserId, type, payload }) => {
    // Both sender and target must be in the room
  },
});

export const inboxFor = query({
  args: { roomId: v.id('rooms'), since: v.number() },
  handler: async (ctx, { roomId, since }) => {
    // Returns messages addressed to current user since timestamp
  },
});

Client Example

// Client: send an offer
const send = useMutation(api.signaling.sendMessage);
await send({
  roomId,
  toUserId: peerId,
  type: 'offer',
  payload: { sdp: pc.localDescription },
});

// Client: receive messages
const inbox = useQuery(api.signaling.inboxFor, { roomId, since: lastSeenAt });
useEffect(() => {
  if (!inbox) return;
  for (const msg of inbox) {
    handleSignalingMessage(msg);
  }
}, [inbox]);

TURN Credentials

For NAT traversal (~20% of connections need TURN relay):
// packages/backend/convex/signaling.ts
export const turnConfig = action({
  args: {},
  handler: async (): Promise<{ iceServers: RTCIceServer[] }> => {
    // Mints ephemeral TURN creds via Cloudflare Realtime API
    // Falls back to STUN-only if not configured
  },
});
Set CLOUDFLARE_TURN_TOKEN_ID and CLOUDFLARE_TURN_API_TOKEN via npx convex env set.

Signaling Messages Schema

signalingMessages: defineTable({
  roomId: v.id('rooms'),
  fromUserId: v.id('users'),
  toUserId: v.id('users'),
  type: v.union(v.literal('offer'), v.literal('answer'), v.literal('ice')),
  payload: v.any(),
  expiresAt: v.number(),
})
  .index('by_room_and_to_and_creation', ['roomId', 'toUserId'])
  .index('by_expires', ['expiresAt']);
Messages auto-purge after 1 hour via the signaling.purgeExpired cron job.