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.
TanStack Query apps: Realtime also works through `GencowTanstackProvider`. See the TanStack Query guide for adapter-specific cache key and invalidation patterns.
How It Works
Gencow uses two realtime paths depending on whether the query requires authentication.
Public queries (server-side allowAnonymous, mirrored in generated api.ts) can receive fresh data over WebSocket after a mutation — the component re-renders with no HTTP refetch.
Auth-required queries (the default for createCrud()) receive invalidation only over WebSocket. When data changes, the server signals subscribed clients to refetch; the client automatically calls POST /api/query with its JWT, and PostgreSQL RLS runs on that read. Query result data is never transmitted over WebSocket for private queries.
Public query Auth-required query
──────────────── ───────────────────
useQuery(api.posts.listPublic) useQuery(api.tasks.list, {})
│ │
├─ HTTP: initial data ├─ HTTP: initial data
├─ WS: subscribe ├─ WS: subscribe (signed grant)
│ │
[mutation] [mutation]
│ │
├─ WS: push data ──► re-render ├─ WS: invalidate (no data)
│ ├─ HTTP: auto-refetch ──► re-renderuseQueryfetches initial data over HTTP- It subscribes over WebSocket for updates (auth queries use a short-lived subscription grant)
- After a mutation, the server either pushes data (public) or sends invalidate (auth) — built-in
createCrud()uses invalidate for private lists - Your component re-renders automatically — no 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 — invalidation triggers automatic refetch) */}
<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)
// Generated api.ts carries allowAnonymous from the server definition — no hook options needed.
const publicPosts = useQuery(api.posts.listPublic);Note: When your procedure uses
.allowAnonymous()orcreateCrud(table, { allowAnonymous: true }), codegen emits{ allowAnonymous: true }on the matching entry inapi.ts.useQueryreads that default and runs without a JWT — you do not need hook options for public endpoints. The legacy{ public: true }option still works but is deprecated. Server-side access control (Postgres RLS,ownerRls, etc.) is still enforced on the HTTP read —allowAnonymousonly skips the auth gate, it does not bypass row policies.
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 manual refetch — WebSocket invalidation triggers automatic HTTP refetch
};
return (
<button onClick={handleSubmit} disabled={isPending}>
{isPending ? "Creating..." : "Create"}
</button>
);
}Key concept: After
create()completes, the server invalidates related subscriptions over WebSocket. Each connecteduseQuery(api.tasks.list, {})hook refetches over HTTP with its own JWT and re-renders with fresh, RLS-scoped 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.
import { v } from "@gencow/core";
import { procedure } from "./runtime";
export const updatePublicTicker = procedure.mutation
.name("metrics.updatePublicTicker")
.input(v.object({ activeUsers: v.number() }))
.handler(async ({ context: ctx, input }) => {
await ctx.db.insert(publicMetrics).values(input);
ctx.realtime.emit("metrics.publicTicker", input);
});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, v } from "@gencow/core";
import { procedure } from "./runtime";
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 = procedure.mutation
.name("messages.send")
.input(v.object({
workspaceId: v.string(),
conversationId: v.string(),
body: v.string(),
}))
.handler(async ({ context: ctx, input }) => {
const user = ctx.auth.requireAuth();
const message = await insertMessage(ctx.db, user.id, input);
ctx.realtime.channel("conversation.events", {
workspaceId: input.workspaceId,
conversationId: input.conversationId,
}).publish({
type: "message.created",
message,
});
return 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(), orrefresh(), 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 |
| Startup | 5,000 |
| Enterprise | Custom |
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
- Storage — File upload & download
- Cron Jobs — Scheduled tasks
- Deployment — Cloud deployment