Skip to content

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.

  1. 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/install with { "projectId": "...", "tables": ["messages"] } and a console access token; a production environment also needs "allowProduction": true from an owner or admin.
  2. 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.

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.

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 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.

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

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.

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 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 },
});