Realtime channels and presence
Channels let your app’s users talk to each other live: chat, cursors, “who’s online”, game state. A client opens a channel such as room:123, sends broadcasts to everyone else in it, and sees who’s there. Each environment has its own channels: a preview environment’s room:123 is not production’s, and no other project can reach either.
Channel rules
Nobody can join a channel until flatstack/realtime.ts says who may. Export the rules as its default:
import { realtime } from "@flatstackdev/server";
import { eq, and } from "drizzle-orm";
import { members } from "./schema";
export default realtime({
lobby: "public",
"members:*": "authenticated",
"room:*": async ({ user, params, ctx }) => {
if (user === null) return false;
const rows = await ctx.db
.select()
.from(members)
.where(and(eq(members.roomId, params.id), eq(members.userId, user.id)));
return rows.length > 0;
},
"org:{orgId}:chat": ({ org, params }) => org?.id === params.orgId,
"admin:*": "deny",
});
Each key is a channel name or a pattern. A rule is one of:
| Rule | Who may join |
|---|---|
"public" |
Anyone, signed in or not |
"authenticated" |
Any signed-in user of your app |
"deny" |
Nobody |
| A function | Whoever it returns true for |
A function rule gets { user, org, channel, params, ctx }: the signed-in user (or null), their active organisation, the full channel name, what the pattern matched, and the request’s ctx, so it can read the database.
Patterns. Channel names are segments split by :, each made of letters, digits, _, -, . and @, up to 128 characters in all. In a pattern, * matches one segment and arrives as params.id; {name} matches one segment and arrives as params.name. A pattern can have one * and any number of {name} parts. room:* matches room:123 but not room or room:1:2.
Which rule applies. An exact name wins over any pattern. Otherwise the first pattern that matches, in the order you wrote them, decides. A channel no rule matches can’t be joined, and neither can any channel if the project has no flatstack/realtime.ts.
Rules are checked once, when a client joins. A client in a channel may broadcast and set its presence there until it disconnects. To remove someone, close their access in your data and have them reconnect, or send an event your client acts on.
Joining from a browser
Open a WebSocket to /realtime/<channel> on your app’s address:
const ws = new WebSocket(`wss://${location.host}/realtime/room:123`);
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
if (message.type === "broadcast") console.log(message.event, message.payload);
};
ws.onopen = () => ws.send(JSON.stringify({ type: "broadcast", event: "message", payload: { text: "hi" } }));
The browser sends your app’s sign-in cookie with the connection, so user in the rules is whoever is signed in. Apps that sign in with a mobile token (see sign-in) send it as Authorization: Bearer <token> on the connection request, which most WebSocket libraries outside the browser allow.
One socket is one channel. To be in two channels, open two sockets; each counts as a connection (see Limits). The @flatstackdev/client SDK will wrap this as client.realtime.channel("room:123").
If the join is refused, the connection fails before it opens, with the usual error body:
| Status | Code | When |
|---|---|---|
| 401 | unauthenticated |
The rule needs a signed-in user and there’s none. Sign in and reconnect. |
| 403 | forbidden |
The rule said no, or no rule matches the channel. |
| 400 | validation_failed |
The channel name isn’t valid, or the request wasn’t a WebSocket upgrade. |
| 503 | realtime_capacity |
The account is at its connection limit. |
| 429 | requests_capped or cpu_capped |
The account is at its request or CPU limit, as for any request to your code. |
Messages
Everything on the socket is JSON text. A client sends:
| Message | What happens |
|---|---|
{ "type": "broadcast", "event": "message", "payload": … } |
Everyone else in the channel gets it. The sender doesn’t get its own copy. |
{ "type": "presence", "state": … } |
Sets this connection’s presence state, and tells everyone else. |
{ "type": "ping" } |
Answered with { "type": "pong" }. |
Event names are up to 64 letters, digits, _, ., : or -. The client gets:
| Message | When |
|---|---|
{ "type": "joined", "channel", "connectionId", "members": [...] } |
First, on every connection: your connection’s ID and everyone in the channel, you included. |
{ "type": "broadcast", "event", "payload", "from" } |
Someone broadcast. from is their connection ID, or null when your server code sent it. |
{ "type": "presence_join", "member" } |
Someone joined. |
{ "type": "presence_update", "member" } |
Someone set their presence state. |
{ "type": "presence_leave", "member" } |
Someone disconnected. |
{ "type": "error", "code", "message" } |
The service couldn’t take your last message (not JSON, too large, an unknown type). The socket stays open. |
Presence
Every open connection is a member of its channel:
{ "connectionId": "6f1c…", "userId": "u_123", "state": { "typing": true }, "joinedAt": "2026-10-30T09:12:00.000Z" }
userId is the signed-in user, or null for a guest on a public channel. One user with two tabs open is two members. state is whatever the connection last sent in a presence message, up to 1 KB as JSON, and null until then. Members come in the order they joined.
From server code
Functions, routes and cron jobs can send to any channel in their environment, whatever the rules say, and read who’s there:
export const postMessage = fn({
auth: "required",
input: z.object({ roomId: z.string(), text: z.string().max(2000) }),
handler: async ({ input, ctx }) => {
// Save it, then tell everyone in the room.
const { sent } = await ctx.realtime.broadcast(`room:${input.roomId}`, "message", {
text: input.text,
from: ctx.user.id,
});
return { delivered: sent };
},
});
const here = await ctx.realtime.presence("room:42"); // PresenceMember[]
broadcast resolves with how many connections it reached. A broadcast to an empty channel reaches none and isn’t an error. Sending from a function is the way to broadcast something you’ve checked or saved first: clients’ own broadcasts aren’t stored or validated beyond their size.
Limits
| Limit | Hobby | Pro | Studio |
|---|---|---|---|
| Concurrent connections | 100 | 500 | 2,500 |
Unclaimed sandboxes have 10. Connections are pooled across every project in the account, as all usage limits are. On every plan:
- A message can be up to 64 KB as JSON text.
- Presence state can be up to 1 KB as JSON.
- Channel names can be up to 128 characters.
At 80% of the connection limit you get an email and a dashboard banner. At 100%, new connections are refused with 503 realtime_capacity; connections already open carry on, and a place frees up as soon as one closes. A connection lost without a clean close (a network drop the server never heard about) stops counting within 10 minutes. Opening a channel counts as a request to your app, like any other.
Coming from Supabase Realtime
The Supabase importer lists what your project used in MIGRATION_REPORT.md, under Realtime. Broadcast and Presence map to channels here: supabase.channel("room:1") becomes a WebSocket on /realtime/room:1, and each policy on realtime.messages becomes a rule in flatstack/realtime.ts. Postgres Changes have no equivalent yet: after a write, send the change yourself with ctx.realtime.broadcast from the function that made it.
Limitations
- Messages aren’t stored. A client that’s offline when a broadcast goes out doesn’t get it later. Save what matters with a function and broadcast after.
- Rules are checked at join, not per message.
flatstack devdoesn’t run realtime yet; channels work once deployed.- Live queries (
client.live, re-running a query when its tables change) are coming in a later release.
As Markdown: apps/realtime.md