Realtime overview
Realtime streams row changes (INSERT, UPDATE, DELETE) from your tables to connected clients, and carries broadcast messages and presence between them. Each client opens one WebSocket to wss://api.vaif.studio/realtime/ws and shares it across every subscription.
Before you start
Section titled “Before you start”- Turn realtime on for the table. Only tables with realtime on send changes. Turn it on under Realtime > Tables in the console, or on the table’s Realtime tab in the Table Editor. A project developer can also call
POST /realtime/installwith{ "projectId": "...", "tables": ["messages"] }and a console access token; a production environment also needs"allowProduction": truefrom an owner or admin. - Use a publishable key. The realtime connection needs an API key (
vk_pub_…in browsers). Pass the signed-in user’s access token too, so row rules apply to the events that user receives.
Subscribe to row changes
Section titled “Subscribe to row changes”Call .on() on a table, then .subscribe():
import { createVaifClient } from "@vaifstudio/client";
const vaif = createVaifClient({ baseUrl: "https://api.vaif.studio", projectId: process.env.VAIF_PROJECT_ID, apiKey: process.env.VAIF_PUBLISHABLE_KEY,});
type Message = { id: string; room_id: string; body: string };
const subscription = vaif .from<Message>("messages") .eq("room_id", "room-42") .on("INSERT", (event) => console.log("new message", event.new)) .on(["UPDATE", "DELETE"], (event) => console.log(event.operation, event.new ?? event.old)) .subscribe((status, error) => { if (status === "CHANNEL_ERROR") console.error("subscription refused", error); });
// Later, when the screen closes:subscription.unsubscribe();The client connects on the first subscribe() and closes the socket a second after the last subscription is removed. You do not call connect() yourself.
What an event contains
Section titled “What an event contains”| Field | Meaning |
|---|---|
operation |
"INSERT", "UPDATE" or "DELETE" |
table, schema |
Where the change happened |
new |
The row after the change (absent on DELETE) |
old |
The row before the change, on UPDATE and DELETE |
timestamp |
When the change was sent |
partial |
true when the row was too large to send whole. Read it again with vaif.from(table).get(id) |
Filters
Section titled “Filters”Filters on the chain become server-side filters, so a client only receives rows it asked for:
type Order = { id: string; store_id: string; total: number; status: string };
vaif .from<Order>("orders") .eq("store_id", "store-7") .gte("total", 100) .in("status", ["paid", "shipped"]) .select("id", "status", "total") .on("*", (event) => console.log(event.new)) .subscribe();Realtime supports eq, neq, gt, gte, lt, lte, in, and an AND of those inside .where(). .select() limits the columns sent. A filter realtime cannot apply (like, ilike, OR, NOT) makes .on() throw a VaifValidationError, so a subscription never silently receives rows that the same query’s .list() would not return.
Subscription status
Section titled “Subscription status”subscribe() takes an optional callback that receives the status:
| Status | Meaning |
|---|---|
SUBSCRIBING |
Waiting for the server to confirm. You also see this after a reconnect |
SUBSCRIBED |
Confirmed. Events are flowing |
CHANNEL_ERROR |
The server refused it, for example an unknown table or column |
CLOSED |
Unsubscribed, or the client stopped for good |
Authentication and row rules
Section titled “Authentication and row rules”Each connection sends its credentials in its first message, never in the URL. Pass accessToken as a function so the client can fetch a fresh token before the current one expires:
import { createVaifClient } from "@vaifstudio/client";
let accessToken: string | null = null; // set this after sign-in
const vaif = createVaifClient({ baseUrl: "https://api.vaif.studio", projectId: process.env.VAIF_PROJECT_ID, apiKey: process.env.VAIF_PUBLISHABLE_KEY, accessToken: () => accessToken ?? "",});
// After sign-out, stop applying the old identity to the live connection:vaif.getRealtimeClient().setAuth(null);Row changes pass through the same row rules as GET /v1/data/...: a row the caller cannot read through the data API is not sent as an event either.
Connection state and errors
Section titled “Connection state and errors”vaif.getRealtimeClient() returns the shared connection:
const realtime = vaif.getRealtimeClient();
realtime.on("state", (info) => console.log("connection is", info?.state));realtime.on("error", (info) => console.error(info?.code, info?.message));
// Mobile apps: close the socket in the background, keep every subscription.realtime.pause();realtime.resume();The client reconnects with exponential backoff and jitter, and resubscribes everything after it reconnects. It stops retrying when the server refuses the credentials outright:
| Close code | Meaning | What the client does |
|---|---|---|
4000 |
Bad request | Stops and reports the error |
4001 |
Unauthorized (bad or missing API key) | Stops and reports the error |
4002 |
Access token expired | Fetches a fresh token (when accessToken is a function) and reconnects once |
4003 |
Forbidden | Stops and reports the error |
4004 |
Not found | Stops and reports the error |
4008 |
Authentication timed out | Reconnects with backoff |
4029 |
Connection limit reached | Reconnects with backoff |
The number of connections a project can hold at once depends on its plan. See Plans.
Node.js and other runtimes
Section titled “Node.js and other runtimes”Node.js 22 and later, and every supported browser, have a built-in WebSocket, so nothing extra is needed. A runtime without one, or a test that wants to watch the socket, can pass its own implementation, such as the ws package:
import WebSocket from "ws";import { createVaifClient } from "@vaifstudio/client";
const vaif = createVaifClient({ baseUrl: "https://api.vaif.studio", projectId: process.env.VAIF_PROJECT_ID, apiKey: process.env.VAIF_PUBLISHABLE_KEY, realtime: { WebSocket },});Next steps
Section titled “Next steps”- Channels and presence: broadcast messages and see who is online
- Realtime in React:
useRealtime,useChannelandusePresence