BUILT INTO EVERY GOODSPEED APP
Realtime Presence (Who's Online)
A single hook tracks which users are currently active in a Supabase Realtime channel with join/leave events, enabling who's viewing indicators, multi-cursor collaboration, and live counts with no custom backend.
- Tier: Common
- Status: Template pattern
- Always enabled
WHY IT MATTERS
Collaborative features live or die on awareness. When users cannot see who else is looking at the same document, viewing the same dashboard, or editing the same record, they step on each other, duplicate work, and lose trust in the product. Bolting on presence later means a custom WebSocket server, a separate pub/sub service, or a polling loop that burns battery and server time. The gas-template ships usePresence(), a single React hook that connects to a named Supabase Realtime channel and hands you a live list of every peer currently joined, with no infrastructure beyond the Supabase project you already have. The hook emits join and leave events synchronously as they arrive, so a 'who's viewing' pill updates the moment a second user opens the screen. Features like live typing indicators, shared cursors, and viewer counts all become a few lines of JSX on top of the same hook, because the WebSocket lifecycle management is already handled.
Under the hood, usePresence() routes through the shared channel cache in services/realtime.ts. That cache refcounts callers, so two components on the same screen that each call usePresence('document-123') share one WebSocket rather than opening two. The channel is identified by a per-user presence key (defaults to user_id from your payload, falls back to a random UUID for anonymous sessions), which is what Supabase uses to deduplicate state. When the component unmounts, channel.untrack() and releaseChannel() fire automatically, and if the reference count drops to zero the channel idles for a minute before the WebSocket closes. The result is a presence layer that scales to dozens of concurrent users per channel with no custom backend, no polling, and no cleanup debt.
HOW IT IS WIRED
Real code from the GAS template
The code below is drawn from hooks/usePresence.ts in the gas-template repository. This is the code your generated app gets, not pseudocode, not a description of intent.
// hooks/usePresence.ts: tracks who is currently online in a channel
export function usePresence(
channelName: string,
payload: Record<string, unknown>,
): UsePresenceReturn {
const [peers, setPeers] = useState<PresencePeer[]>([]);
const [status, setStatus] = useState<UsePresenceReturn['status']>('joining');
useEffect(() => {
const presenceKey =
(payload.user_id as string | undefined) ?? Crypto.randomUUID();
const cacheKey = presenceCacheKey(channelName, presenceKey);
const entry = acquireChannel(channelName, {
cacheKey,
createChannel: () =>
supabase.channel(channelName, {
config: { presence: { key: presenceKey } },
}),
});
const channel = entry.channel;
const syncPeers = () => {
const flat = Object.values(
channel.presenceState<Record<string, unknown>>()
).flatMap((arr) => arr as PresencePeer[]);
setPeers(flat);
};
channel
.on('presence' as any, { event: 'sync' }, syncPeers)
.on('presence' as any, { event: 'join' }, syncPeers)
.on('presence' as any, { event: 'leave' }, syncPeers);
let cancelled = false;
entry.subscribed
.then(() => {
if (!cancelled) { setStatus('joined'); channel.track(payload); }
})
.catch(() => { if (!cancelled) setStatus('error'); });
return () => {
cancelled = true;
channel.untrack();
releaseChannel(channelName, cacheKey);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [channelName]);
return { peers, status };
}Source: goodspeed-apps/gas-template → hooks/usePresence.ts
HONEST LIMITS
When Realtime Presence (Who's Online) is the wrong choice
Presence only pays for itself when multiple users genuinely benefit from knowing each other are online at the same time. Single-user productivity apps, async content-consumption apps, and tools where collaboration is an occasional edge case rather than a core workflow will open a Supabase Realtime WebSocket on every session for no user-visible benefit. Supabase Realtime has a concurrent connection limit tied to your plan tier. Enabling presence in an app used by tens of thousands of users simultaneously can exhaust that budget faster than broadcast-only or Postgres-change-stream features. For apps that need presence across many independent rooms or documents simultaneously (say, a whiteboard product where every canvas is its own channel), the single-channel model in usePresence() is correct but the connection budget scales with active rooms, not with total users. If your architecture involves hundreds of concurrent named channels per tenant, audit your Supabase plan's realtime connection ceiling before shipping presence in production. The offline-first-sync feature is the wrong pairing here: presence is inherently online-only, and tracking who is 'online' while most sessions are actually offline produces misleading indicators.
Tier: Common · Template pattern
Evaluate your use case
Check whether realtime presence (who's online) 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 presence (who's online). Browse the ideas catalog to see apps across all categories that ship with this feature wired in.
CAPABILITIES
Realtime Presence (Who's Online) capability breakdown
Concrete dimensions of what the built-in realtime presence (who's online) implementation covers. These reflect the actual template code, not a marketing summary.
| Item | Description | Strength |
|---|---|---|
| Channel model | Each usePresence() call joins a named Supabase Realtime presence channel. The underlying WebSocket is refcounted and shared across callers via the module-level channel cache in services/realtime.ts. | Supabase Realtime |
| Presence key | Each peer is identified by a presence key (defaults to user_id from the payload, falling back to a random UUID for anonymous sessions). Supabase uses this key to deduplicate join/leave events across multiple tabs. | Per-user key |
| Auto-cleanup | channel.untrack() and releaseChannel() fire automatically on React unmount. When the refcount drops to zero the channel idles for a minute then the WebSocket closes and the entry is evicted from the cache. | Auto on unmount |
| Connection reuse | acquireChannel() refcounts callers so two components calling usePresence() with the same channel name share one underlying WebSocket rather than opening two connections. | Refcounted cache |
| Status surface | The hook returns a status field: 'joining' while subscribing, 'joined' once SUBSCRIBED and track() is sent, 'error' on subscribe failure. Components can render loading and error states without polling. | joining / joined / error |
COMMON QUESTIONS
Does usePresence work on iOS Safari and web?
Yes. Supabase Realtime uses WebSockets, which are supported on iOS Safari, Chrome, Firefox, and every modern mobile WebView. The hook itself is written in plain React with no native-only dependencies, so it runs identically on React Native (iOS and Android) and Expo's web target without modification. The one caveat is that background tabs on iOS Safari may throttle WebSocket connections when the tab is not in the foreground, which can delay presence-leave events by a few seconds. For most 'who's viewing' use cases that delay is invisible to users. If you need guaranteed sub-second leave detection on iOS web, keep the presence-sensitive UI on React Native or add an application-layer heartbeat so the server can detect stale sessions faster.
How is presence state conflict resolved when two users join at the same millisecond?
Supabase Realtime presence uses a CRDT-style server-side merge. Each peer's state is keyed by a unique presence_ref assigned by the server, so two simultaneous joins never collide. The sync event fires only after the server has merged all pending joins, so the peers array returned by usePresence() is always a consistent, deduplicated snapshot. You do not need to handle merge conflicts in application code. If you include mutable state in the payload (cursor position, selected item) and two users update the same key simultaneously, last-write-wins semantics apply: the most recent track() call from the server's perspective overwrites older values. This is acceptable for ephemeral UI state like cursors but unsuitable for authoritative business data.
What happens if the WebSocket connection drops mid-session?
Supabase Realtime automatically attempts to reconnect when the connection drops. During the reconnect window the hook's status field transitions from 'joined' back to 'joining', and the peers array reflects the last known state until the channel re-subscribes. Once re-subscribed (status returns to 'joined'), usePresence() calls channel.track(payload) again to re-announce the current user. The channel cache in services/realtime.ts does not reconnect on its own; it relies on the Supabase Realtime client's built-in exponential backoff. On a mobile device moving in and out of coverage, expect reconnect latency of one to five seconds. Surface the 'joining' status in your UI (e.g. grayed-out presence avatars) so users understand the presence data may be momentarily stale.
Can I pass arbitrary metadata with each presence entry?
Yes. The payload parameter to usePresence() is typed as Record<string, unknown>, so you can include any JSON-serializable fields alongside user_id: cursor x/y coordinates for multi-cursor UI, the ID of the document section the user is currently reading, a display name, avatar URL, or an activity label like 'editing' vs 'viewing'. Every field you pass is broadcast to all subscribers and is accessible in the peers array as PresencePeer properties. Keep payload size below 1 KB per peer. Presence state is gossiped to all subscribers on every join, leave, and sync cycle, so oversized payloads accumulate bandwidth costs quickly on channels with many concurrent users.
How many concurrent users can one presence channel support?
Presence channels are designed for low-to-moderate concurrency within a single channel. For typical collaborative features like document co-editing, 'who's viewing' indicators, or shared dashboards, channels with 2-50 concurrent peers perform well. Above roughly 100 simultaneous peers you may see increased gossip overhead because every join and leave event broadcasts to all subscribers. For high-concurrency scenarios (live events, large classrooms, public feeds), partition users into sub-channels keyed by room, section, or tenant rather than putting everyone into one channel. The hook accepts any string as channelName, so partitioning is as simple as passing a computed key like 'doc-session-${documentId}' to each usePresence() caller. The connection budget is per Supabase project, not per channel, so the total concurrent WebSocket connections across all channels in your app count against your plan tier limit.
GET IT BUILT INTO YOUR APP