TL;DR: Realtime lets your UI react to database changes the instant they happen instead of polling. Subscribe to a table and you get an event whenever a row is inserted, updated, or deleted. It's delivered over Server-Sent Events and it's row-level-security filtered โ a signed-in user only receives the rows they're allowed to see. One call: lingcode.from('table').subscribe(onChange), which returns an unsubscribe function you call on teardown.
Most apps fake "live" with a timer: re-fetch every few seconds and hope nothing important happened in between. That's wasteful when nothing changed and laggy when something did. Realtime flips it โ the server pushes you the row the moment it changes, so your chat, dashboard, or shared list updates the instant the write lands, and stays quiet otherwise. This page covers why you'd use it, the SDK, a complete example, and what's happening under the hood.
Polling trades freshness against load: poll often and you hammer the backend for mostly-unchanged data; poll rarely and your UI feels stale. Realtime removes the trade. The subscription holds an open stream, and the server sends you a small event only when a row you can see actually changes. Reach for it whenever the screen should reflect what other people (or other tabs, or a background job) just did:
Realtime reflects the same data your queries return โ it's not a separate copy. So the natural pattern is to combine an initial .select() to load history with a .subscribe() to live-tail everything after.
One method on the table builder:
client.from('table').subscribe(onChange, onError?) // โ unsubscribe
Your onChange callback receives a single event object each time a row changes:
{
table: string, // which table the change came from
type: "INSERT" | "UPDATE" | "DELETE", // what happened
row: object // the affected row
}
The return value of subscribe is an unsubscribe function. Call it when you're done โ typically on component unmount โ to close the stream. Keeping the handle and calling it is the one rule that matters; forget it and you leak open streams.
const off = lingcode.from('messages').subscribe(({ type, row }) => {
if (type === 'INSERT') appendMessage(row);
});
// later, on teardown:
off();
Every change is re-checked against the subscriber's permissions before it's delivered, so users never receive rows they couldn't read with a normal query โ the same row-level security that gates your .select() gates your subscription. Because a deleted row is already gone, DELETE events fall back to a best-effort ownership check. And very large rows โ near the database's notification size limit (~8 KB) โ are delivered as a lightweight "something changed, refetch" signal rather than the full row. Be ready for that: if a change arrives without a full row payload, re-query to get the current state.
Here's the whole loop for a chat view: load the recent history once with .select(), then subscribe to live-append new messages, then unsubscribe on teardown. Inside /try and the Mac preview, lingcode already exists; in your own app, create the client first (see the API reference).
// 1. Load history.
const { data: history } = await lingcode
.from('messages')
.order('created_at', { ascending: true })
.limit(100)
.select();
for (const row of history) appendMessage(row);
// 2. Live-tail everything after.
const off = lingcode.from('messages').subscribe(
({ type, row }) => {
if (!row) { reloadMessages(); return; } // large-row "refetch" signal
if (type === 'INSERT') appendMessage(row);
if (type === 'UPDATE') replaceMessage(row);
if (type === 'DELETE') removeMessage(row);
},
(err) => console.error('realtime stream error', err)
);
// 3. On teardown (e.g. component unmount), close the stream.
function teardown() {
off();
}
That's the canonical shape: an initial read for state, a subscription for the live tail, and an unsubscribe handle you actually call.
The SDK's subscribe opens an SSE connection to one endpoint:
GET https://lingcode.dev/api/cloud/be/<backend-id>/realtime
It's a Server-Sent Events stream โ the response is Content-Type: text/event-stream. Authenticate with your anon key or a signed-in user token (the same credential you use for queries; it's what scopes the RLS filtering). An optional query parameter narrows which tables you hear about:
GET /api/cloud/be/<backend-id>/realtime?table=messages,presence
The ?table= filter is comma-separated and limits the stream to those tables. Each change arrives as an SSE change event:
event: change
data: {"table":"messages","type":"INSERT","row":{"id":42,"body":"hi"}}
The stream also sends a heartbeat about every 25 seconds to keep the connection alive through proxies and idle timeouts. The SDK handles all of this for you โ the endpoint is here so you can see exactly what the subscription is doing, or consume it directly from a non-JS client.
Realtime streams row changes from your tables. That's the whole surface. It is deliberately not:
The good news is you rarely need those as separate systems. For arbitrary server→client messaging, write to a table and subscribe to it โ insert a row to send, and every subscriber gets the change. The same trick gives you presence: keep a presence table, write a heartbeat row, and subscribe to it.
subscribe hands you a function for a reason โ call it when the view goes away. An unclosed subscription is an open stream that keeps consuming a connection. In component frameworks, return off from your effect's cleanup; in plain JS, call it in your teardown path.