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:

  1. Authentication is required — the server must know who each subscriber is
  2. Membership is tracked server-side — not just "someone joined" but "user ID 42, Alice, joined"
  3. 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:

  1. Their connection is added to the Redis Hash
  2. A member_added event is published to all existing members
  3. The new member receives subscription_succeeded with the full current member list

When a member unsubscribes or disconnects:

  1. Their connection is removed from the Hash
  2. A member_removed event 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.