Presence Channels Explained
Presence channels let you track who's online in real time — showing typing indicators, viewer counts, and live cursors without building your own member tracking.
Knowing who else is in the room is a fundamental feature of collaborative software. Slack shows you who's online. Figma shows you who's viewing the file. Google Docs shows you who's editing. These features feel simple from the user's perspective — but implementing them correctly requires careful distributed systems thinking.
Presence channels solve this problem at the infrastructure level.
What a Presence Channel Is
A presence channel is a WebSocket channel with built-in member tracking. When a client subscribes, it registers its user data with the server. When it unsubscribes (or disconnects), it's removed. All members receive notifications when membership changes.
This differs from a regular channel in three ways:
- Authentication is required — the server must know who each subscriber is
- Membership is tracked server-side — not just "someone joined" but "user ID 42, Alice, joined"
- Join/leave events are broadcast — every member sees when others arrive and depart
Use Cases
Online presence indicators — show which team members are currently active. The Slack-style green dot.
Typing indicators — detect when another user is composing a message in a shared space.
Live cursors — track where other users' cursors are in a shared document or canvas.
Viewer counts — "12 people are watching this live stream right now."
Co-editing sessions — know exactly which users have a document open, enabling conflict resolution.
How It Works Under the Hood
Authentication
Presence channels require a signed subscription token from your server. The client requests authorization; your backend verifies the user's identity and signs a token containing their user data:
// Your server — sign the channel subscription
app.post('/auth/channel', (req, res) => {
const { socket_id, channel_name } = req.body;
const user = getUserFromSession(req);
if (!user) return res.status(401).json({ error: 'Unauthorized' });
const auth = apinator.authorizeChannel(socket_id, channel_name, {
user_id: user.id,
user_info: { name: user.name, avatar: user.avatarUrl },
});
res.json(auth);
});
The client sends this signed token when subscribing. The WebSocket server verifies the signature and extracts the user data.
Member Registry
Member data is stored in a shared registry — typically a Redis Hash keyed by channel name, with one entry per connection:
Redis Hash: presence:document-123
conn_a8f3 → { "user_id": "42", "name": "Alice", "avatar": "..." }
conn_b2c1 → { "user_id": "57", "name": "Bob", "avatar": "..." }
conn_e9d4 → { "user_id": "42", "name": "Alice", "avatar": "..." } ← Alice, second tab
Join and Leave Events
When a new member subscribes:
- Their connection is added to the Redis Hash
- A
member_addedevent is published to all existing members - The new member receives
subscription_succeededwith the full current member list
When a member unsubscribes or disconnects:
- Their connection is removed from the Hash
- A
member_removedevent is published to remaining members
Multi-Tab Handling
Users often have the same app open in multiple tabs. A naive implementation would show the same user joining multiple times and leaving when they close one tab — even though they're still present.
Proper presence channels track connections, not users. The member list is deduplicated by user ID for display purposes, but each connection is tracked separately. The user appears as "left" only when their last connection closes.
Alice opens tab 1 → member_added: Alice (first connection)
Alice opens tab 2 → no event (already present)
Alice closes tab 1 → no event (still has tab 2)
Alice closes tab 2 → member_removed: Alice (last connection)
Using Presence Channels with Apinator
Subscribing from the client
import { RealtimeClient } from '@apinator/sdk';
const client = new RealtimeClient({
appKey: 'your-app-key',
authEndpoint: '/auth/channel',
});
const channel = client.subscribe('presence-document-123');
channel.bind('realtime:subscription_succeeded', (data) => {
renderMemberList(data.members);
});
channel.bind('realtime:member_added', (member) => {
addMemberToUI(member);
});
channel.bind('realtime:member_removed', (member) => {
removeMemberFromUI(member);
});
Sending presence signals to other members
Presence channels are also regular channels — you can bind custom events on them. Typing indicators are a common example:
inputEl.addEventListener('input', () => {
channel.trigger('client-typing', { userId: currentUser.id });
});
channel.bind('client-typing', (data) => {
showTypingIndicator(data.userId);
});
client- prefixed events are routed directly from one client to other channel members, with no roundtrip to your server.
Heartbeats and Stale Connection Cleanup
WebSocket connections can die silently — mobile clients lose signal, laptop lids close, browser tabs crash. The TCP connection doesn't close cleanly, so the server never receives a close frame.
To detect stale connections, presence channels use heartbeats. Each connection periodically sends a ping; the server responds with a pong. Connections that stop heartbeating are treated as disconnected and removed from the member registry.
Redis sorted sets work well here: the score is the last-seen timestamp. A background job removes members whose scores are older than the heartbeat timeout.
When to Use Presence
Use a presence channel when:
- You need to know who specifically is subscribed, not just how many
- You need join/leave notifications broadcast to all members
- You're building features where users should be aware of each other
Use a regular private channel when you're only broadcasting data and don't need member tracking. Presence adds overhead — auth, member storage, join/leave events — that isn't worth paying for if you don't need the membership information.