Realtime

WebSocket push model — automatic data synchronization

Gencow includes built-in realtime via WebSocket. When data changes on the server, all connected clients receive updates automatically — no manual refetching, no polling.

How It Works

React Component                    Gencow Server
    │                                      │
    ├── useQuery(api.tasks.list) ──────────┤
    │   ◄── WebSocket subscribe ───────────┤
    │   ◄── Initial data (HTTP) ───────────┤
    │                                      │
    │                    [Another client calls mutation]
    │                                      │
    │   ◄── WebSocket push (fresh data) ───┤
    │   └── Component re-renders          │
    │       automatically!                 │
  1. useQuery makes an HTTP request to fetch initial data
  2. It also subscribes via WebSocket for updates
  3. When any mutation modifies related data, the server pushes fresh data to all subscribers
  4. Your component re-renders with the new data — zero manual refetching

useQuery — Reactive Data

import { useQuery } from "@gencow/react";
import { api } from "../gencow/api";

function TaskList() {
    // useQuery returns { data, isLoading, isFetching, error, refetch }
    // createCrud().list returns { data: Task[], total: number }
    const { data: result, isLoading, isFetching, refetch } = useQuery(api.tasks.list);

    if (isLoading && !result) return <div>Loading...</div>;

    return (
        <div>
            <ul>
                {result?.data.map((t) => (
                    <li key={t.id}>{t.title}</li>
                ))}
            </ul>
            {/* Manual refetch (rarely needed — WebSocket handles this) */}
            <button onClick={refetch} disabled={isFetching}>Refresh</button>
        </div>
    );
}

With Arguments

const task = useQuery(api.tasks.getById, { id: 42 });

Conditional Queries (Skip)

// Method A: "skip" token — recommended
const { data: messages } = useQuery(
    api.chat.getMessages,
    conversationId ? { conversationId } : "skip"
);

// Method B: enabled option (TanStack Query-style)
const { data: messages } = useQuery(
    api.chat.getMessages,
    { conversationId },
    { enabled: !!conversationId }
);
// → skip state returns undefined (no API call, no WS subscription)

Public Queries (No Auth)

// By default, useQuery skips if no auth token is available.
// Use { public: true } to call the API without a token (e.g., landing pages).
const publicPosts = useQuery(api.posts.listPublic, {}, { public: true });

Note: { public: true } is a client-side option — it tells useQuery "call the server even without a JWT." This is separate from Postgres RLS which controls server-side data access. You need both for a public endpoint: the table must not have an ownerRls policy blocking reads, and you need { public: true } on the client.

useMutation — Write + Auto-Refresh

import { useMutation } from "@gencow/react";
import { api } from "../gencow/api";

function CreateTask() {
    const { mutate: create, isPending, error } = useMutation(api.tasks.create);

    const handleSubmit = async () => {
        await create({ title: "New task" });
        // No need to refetch! WebSocket pushes update automatically
    };

    return (
        <button onClick={handleSubmit} disabled={isPending}>
            {isPending ? "Creating..." : "Create"}
        </button>
    );
}

Key concept: After create() completes, the server broadcasts updated data via WebSocket. All useQuery(api.tasks.list) hooks across all connected tabs/clients will re-render with fresh data.

Server-Side Realtime: invalidate(), refresh() & emit()

When writing custom write procedures (instead of createCrud()), use ctx.realtime to push updates to connected clients:

ctx.realtime.invalidate(queryKey)

Signals subscribed clients to refetch with their own auth token and query args. Recommended for private/RLS queries and most list updates.

import { procedure } from "./runtime";

export const markAllDone = procedure.mutation
    .name("tasks.markAllDone")
    .handler(async ({ context: ctx }) => {
        const session = ctx.auth.requireAuth();
        await ctx.db.update(tasks)
            .set({ done: true })
            .where(eq(tasks.userId, session.user.id));

        ctx.realtime.invalidate("tasks.list");
    });

ctx.realtime.refresh(queryKey)

Re-runs a query handler on the server and pushes the result to all WebSocket subscribers. Use this for public or server-safe aggregate queries where the server can safely recompute the result without a user-specific auth context.

ctx.realtime.emit(queryKey, data)

Push specific data directly without re-running the query. Use this only for public or opaque data that is safe for every subscriber of that query key.

Do not use emit() to push private/RLS row payloads. For private lists, call invalidate() so each client refetches with its own auth and query args. For private payload push, use an authorized realtime channel.

export const updatePublicTicker = mutation("metrics.updatePublicTicker", {
    args: { activeUsers: v.number() },
    handler: async (ctx, args) => {
        await ctx.db.insert(publicMetrics).values(args);
        ctx.realtime.emit("metrics.publicTicker", args);
    },
});

Authorized Realtime Channels

Use authorized channels when you need to push private payloads without refetching the whole query. The app authorizer runs with the current user context, signs a short-lived grant, and the gateway only fans out to sockets whose identity matches that grant.

import { defineRealtimeChannels, mutation, v } from "@gencow/core";

export const realtimeChannels = defineRealtimeChannels({
    "conversation.events": {
        args: v.object({ workspaceId: v.string(), conversationId: v.string() }),
        channelKey: (args) => `workspace:${args.workspaceId}:conversation:${args.conversationId}`,
        authorize: async (ctx, args) => {
            const user = ctx.auth.requireAuth();
            await assertConversationMember(ctx.db, user.id, args);
            return {
                ttlSeconds: 300,
                epoch: await getConversationMembershipEpoch(ctx.db, args),
            };
        },
    },
});

export const sendMessage = mutation("messages.send", {
    args: { workspaceId: v.string(), conversationId: v.string(), body: v.string() },
    handler: async (ctx, args) => {
        const user = ctx.auth.requireAuth();
        const message = await insertMessage(ctx.db, user.id, args);

        ctx.realtime.channel("conversation.events", args).publish({
            type: "message.created",
            message,
        });
    },
});

When membership, resource access, or privacy changes, bump the app-side epoch and revoke the old subscribers. Clients with access will reauthorize; clients without access will stop receiving the channel payload.

ctx.realtime.channel("conversation.events", { workspaceId, conversationId }).revoke({
    epoch: newMembershipEpoch,
    reason: "membership_changed",
});

When to Use What

Scenario Method Why
createCrud() write procedures Automatic Built-in — no code needed
Custom mutation (private/RLS list) ctx.realtime.invalidate("key") Client refetches with its own auth/args
Custom mutation (public aggregate) ctx.realtime.refresh("key") Server safely recomputes once and pushes
Custom mutation (opaque/public exact channel) ctx.realtime.emit("key", data) Direct push without re-query
Private payload push authorized realtime channel Socket identity + app authorizer gate fanout
High-frequency public updates ctx.realtime.emit("key", data) Avoids re-running query

Warning: If a custom mutation doesn't call emit(), invalidate(), or refresh(), the server logs a warning — connected clients won't see the update until they refresh manually.

useStatus — Connection Health

Monitor the WebSocket connection status:

import { useStatus } from "@gencow/react";

function ConnectionIndicator() {
    const { isConnected } = useStatus();

    return (
        <span style={{ color: isConnected ? "green" : "red" }}>
            {isConnected ? "● Connected" : "○ Disconnected"}
        </span>
    );
}

The WebSocket automatically reconnects with exponential backoff (1s → 1.5s → 2.25s → ... up to 15s).

Cloud Plan Limits

Realtime connections/app means concurrent WebSocket connections subscribed to one deployed app. It is not the number of HTTP visitors or API requests. One browser tab with active realtime queries usually consumes one connection.

Plan Realtime connections/app
Hobby 50
Pro 500
Scale 5,000

On Gencow Cloud, the platform resolves the app owner's plan from user_credits.plan first, then apps.plan, then free. The resolved plan's pricing_tiers.features.wsConnections value is passed to the WebSocket gateway at upgrade time and enforced per app. If the plan row cannot be resolved, the gateway falls back to the platform-level GENCOW_WS_MAX_CONNECTIONS_PER_APP setting.

Self-hosted platforms can change these values in pricing_tiers.features.wsConnections and restart or redeploy the platform process so the gateway uses the updated policy for new connections.

❌ Don't Use fetch() Directly

// ❌ WRONG — bypasses realtime, no auto-refresh
fetch("/api/query", {
    method: "POST",
    body: JSON.stringify({ name: "tasks.list", args: {} }),
});

// ❌ WRONG — creating manual wrappers
const apiPost = (name, args) => fetch("/api/mutation", { ... });

// ✅ CORRECT — use hooks for automatic realtime sync
const { data: tasks } = useQuery(api.tasks.list);
const { mutate: create } = useMutation(api.tasks.create);

Next Steps