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 and whether the query is generated by createCrud().
Public queries (server-side allowAnonymous, mirrored in generated api.ts) can run without a JWT. Public createCrud() list queries subscribe with a public query grant and receive invalidation, so the client refetches over HTTP without auth.
Auth-required queries (the default for createCrud()) also 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.
Custom public queries can still use ctx.realtime.refresh() or ctx.realtime.emit() when the pushed payload is safe for every subscriber.
Public CRUD query Auth-required query
───────────────── ───────────────────
useQuery(api.posts.list, {}) useQuery(api.tasks.list, {})
│ │
├─ HTTP: initial data ├─ HTTP: initial data
├─ WS: subscribe (public grant) ├─ WS: subscribe (signed grant)
│ │
[mutation] [mutation]
│ │
├─ WS: invalidate (no data) ├─ WS: invalidate (no data)
├─ HTTP: auto-refetch ──► re-render ├─ 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 sends invalidate or, for custom public pushes, sends safe data
- 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.
createCrud() List Scopes
Generated CRUD write procedures invalidate list subscribers automatically:
| Operation | List invalidation | Exact get invalidation |
|---|---|---|
create |
Matching list bucket | No |
update |
Old and new matching list buckets | Yes, when get is generated |
remove |
Matching list bucket | Yes, when get is generated |
With the default createCrud(table) config, all list subscribers for the table share one bucket. Use explicit scoped realtime when a table has tenant, workspace, or other partitioned list views:
export const tasksCrud = createCrud(tasks, {
prefix: "tasks",
realtime: {
policy: "authorized",
resourceIdentity: "tasks",
scope: (args) => ({ workspaceId: args?.filters?.workspaceId }),
scopeFromRow: ({ row }) => ({ workspaceId: row.workspaceId }),
authorize: async ({ ctx, args }) => {
const user = ctx.auth.requireAuth();
await assertWorkspaceMember(ctx.db, user.id, args?.filters?.workspaceId);
},
},
});Set realtime: false to disable both list and exact get invalidation. Set realtime: { listRealtime: false } to disable list fanout while preserving exact get({ id }) invalidation for update and remove. Exact generated get watchers still subscribe through the normal generated get query contract, so public CRUD uses a public query grant and protected CRUD uses an authorized query grant.
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",
});Subscribe from React with useRealtimeChannel. Passing null or setting
enabled: false skips the subscription. The hook requests an authorized grant
and automatically resubscribes when the authenticated session or channel args
change.
import { useRealtimeChannel } from "@gencow/react";
function Conversation({ workspaceId, conversationId }) {
useRealtimeChannel(
{
channel: "conversation.events",
args: { workspaceId, conversationId },
},
(event) => {
console.log("conversation event", event);
},
{ enabled: Boolean(conversationId) },
);
return null;
}When to Use What
| Scenario | Method | Why |
|---|---|---|
createCrud() write procedures |
Automatic invalidation | 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 limits count concurrent WebSocket connections, not HTTP visitors or API requests. One browser tab with active realtime queries usually consumes one connection.
| Plan | Per-account connection limit | Per-app safety limit |
|---|---|---|
| Hobby | 1,000 | 500 |
| Startup | 100,000 | 25,000 |
| Enterprise | Custom | Custom |
The account limit is shared by every app owned by the account. The per-app safety limit prevents one app from consuming the entire allowance. Gencow Cloud checks both limits when each WebSocket connection opens. Enterprise limits are set by agreement.
❌ 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