BUILT INTO EVERY GOODSPEED APP
Realtime Broadcast Subscriptions
A broadcast helper fans an arbitrary JSON payload to every client subscribed to a named Supabase Realtime channel, and a React hook subscribes with automatic cleanup. A refcounted cache shares one WebSocket per channel name. Messages are ephemeral: nothing is written to Postgres.
- Tier: Core
- Status: Static
- Always enabled
WHY IT MATTERS
Live, ephemeral signals (a typing indicator, a cursor position, a here-is-what-I-just-did ping) do not belong in a database table, but wiring raw WebSockets to carry them is fiddly and easy to leak. services/realtime.ts gives you a broadcast() helper and a useChannelSubscription hook over Supabase Realtime broadcast channels. broadcast(channelName, event, payload) acquires a channel, waits for the SUBSCRIBED status with a timeout, sends a { type: 'broadcast', event, payload } message to the other subscribers, and releases. The message is fanned out in real time and never written to Postgres, which is the entire point: it is a signal, not a record, so there is no table to migrate and no row to clean up.
Connection management is the part teams usually get wrong, so the template owns it. A module-level cache holds up to 32 channels, refcounted and keyed by name, so ten components subscribed to the same channel share one WebSocket instead of opening ten. Releasing a channel schedules a 60-second idle eviction; re-acquiring before it fires cancels the teardown and keeps the socket warm for fast remounts. A failed subscribe (CHANNEL_ERROR, TIMED_OUT, or CLOSED) evicts the dead entry so the next caller starts fresh rather than reusing a broken channel. useChannelSubscription registers the handler on mount and releases its refcount on unmount. With Supabase SDK defaults, the sender does not receive its own broadcast (self is false) and sends are fire-and-forget (ack is false).
HOW IT IS WIRED
Real code from the GAS template
The code below is drawn from services/realtime.ts in the gas-template repository. This is the code your generated app gets, not pseudocode, not a description of intent.
// services/realtime.ts (broadcast helper, condensed)
export async function broadcast(
channelName: string,
event: string,
payload: Record<string, unknown>,
): Promise<{ ok: boolean }> {
const timeoutMs = gasConfig.realtime?.presenceTimeoutMs ?? BROADCAST_TIMEOUT_MS;
return new Promise<{ ok: boolean }>((resolve, reject) => {
const entry = acquireChannel(channelName, { timeoutMs }); // shared, refcounted
const timer = setTimeout(() => {
releaseChannel(channelName);
reject(new Error(`broadcast timeout on channel "${channelName}"`));
}, timeoutMs);
entry.subscribed
.then(async () => {
const result = await entry.channel.send({ type: 'broadcast', event, payload });
clearTimeout(timer);
releaseChannel(channelName);
if (result === 'ok' || result === 'rate limited') resolve({ ok: true });
else reject(new Error(`broadcast failed: ${result}`));
})
.catch((err) => {
clearTimeout(timer);
releaseChannel(channelName);
reject(err);
});
});
}Source: goodspeed-apps/gas-template → services/realtime.ts
HONEST LIMITS
When Realtime Broadcast Subscriptions is the wrong choice
Do not use broadcast for anything that has to be durable. channel.send sends through Supabase's Realtime broker with no write to Postgres, so a subscriber that is disconnected, backgrounded, or not yet subscribed at send time loses the message with no way to recover it. The 60-second idle cache keeps a socket warm across brief unmounts, but any gap longer than that, or any explicit disconnect, means missed events. For chat history, collaborative document state, or presence that must survive a reconnect, back the state with a Postgres table and subscribe via postgres_changes, which has the written row as its source of truth. Treat broadcast as untrusted at the payload level. The template calls supabase.channel(channelName) with no extra channel config, and it does not assert a user id or JWT claim on the channel name, so any client with the anon key and network access can join a channel by name and receive its broadcasts. Supabase Realtime can be configured server-side to require auth on channels, but that is a project setting, not something this code enforces. Do not put secrets or another user's private data in a broadcast payload; scope sensitive data through RLS-guarded Postgres rows instead.
Tier: Core · Static
Evaluate your use case
Check whether realtime broadcast subscriptions aligns with your target audience, platform constraints, and regulatory environment before enabling it.
Audit the config
This feature is always enabled. If you need to disable it, remove the corresponding template files after generation.
Seek alternatives
If the built-in implementation does not fit, the generated codebase is standard React Native + Expo code. Any library in the Expo ecosystem can replace the default.
APPS USING THIS FEATURE
Every generated Goodspeed app includes realtime broadcast subscriptions. Browse the ideas catalog to see apps across all categories that ship with this feature wired in.
CAPABILITIES
Realtime Broadcast Subscriptions capability breakdown
Concrete dimensions of what the built-in realtime broadcast subscriptions implementation covers. These reflect the actual template code, not a marketing summary.
| Item | Description | Strength |
|---|---|---|
| Ephemeral fanout | broadcast(channelName, event, payload) delivers an arbitrary JSON message to every active subscriber on the named channel in real time, with no row written to Postgres. | No DB write |
| Subscribe + send lifecycle | broadcast handles acquire, await SUBSCRIBED, send, and release in one call; useChannelSubscription wraps the receive path as a React effect that is stable across mounts. | Built in |
| Shared WebSocket | A module-level cache (max 32, refcounted, keyed by name) means multiple callers on the same channel share one WebSocket rather than opening one connection each. | 32-channel cache |
| Recovery on failure | A subscribe that returns CHANNEL_ERROR, TIMED_OUT, or CLOSED evicts the failed cache entry, so the next acquire starts a fresh subscribe instead of reusing a dead channel. | Self-healing |
| Cleanup on unmount | useChannelSubscription releases its refcount in the effect cleanup; the underlying channel tears down via supabase.removeChannel after a 60-second idle window. | 60s idle TTL |
COMMON QUESTIONS
Are broadcast messages durable, and what happens to a subscriber that misses one?
They are not durable. channel.send with type broadcast goes through the Realtime broker with no Postgres write, so if a subscriber is not connected at send time, the message is gone with no replay. The 60-second idle cache keeps a channel's socket warm across short unmounts, which covers quick navigation, but anything beyond that window or an explicit disconnect drops the message. If a client must never miss an update, write the change to a table and subscribe with postgres_changes instead of, or in addition to, broadcast.
How is broadcast different from postgres_changes and presence?
Broadcast sends arbitrary payloads peer to peer through the broker, with no database write, no RLS check on the payload, and no history. postgres_changes fires on real INSERT, UPDATE, or DELETE events in Postgres, is subject to RLS on the row, and has the written row as its source of truth, so it is the durable option. Presence is a third channel type that tracks who is currently subscribed and uses a distinct cache key, because presence channels carry a presence config that broadcast channels do not. Reach for broadcast for ephemeral signals, postgres_changes for durable data, and presence for online state.
Does authentication or RLS protect a broadcast channel?
Not at the payload level in the template. supabase.channel(channelName) is called with no extra channel config and no user-id assertion, so any client holding the anon key can join a channel by name and receive its broadcasts. Supabase Realtime supports server-side authorization on channels as a project setting, which you can enable, but the template code does not enforce it. The safe default is to assume broadcast payloads are public to anyone who knows the channel name, and to keep anything sensitive in RLS-guarded Postgres rows.
What is the channel cache limit, and what happens at the limit?
The cache holds at most 32 channels. When a 33rd channel is acquired, the least-recently-used entry whose refcount is zero is evicted. If every cached channel still has active subscribers, the cache is allowed to grow past 32 rather than tearing down a live subscription, so a busy app does not lose channels it is actively using. In practice you rarely approach 32 distinct live channels, and the refcounting means repeated subscriptions to the same channel name cost one slot, not many.
GET IT BUILT INTO YOUR APP