How to Build Slack with WebSockets

Slack's realtime features — channel messages, typing indicators, online presence, and notifications — can all be built with WebSockets. Here's how the architecture works.

Slack feels instant. Messages appear the moment they're sent. You see when teammates are typing. The green dot tells you who's online. Notifications arrive before you've even looked.

None of this is magic. It's WebSockets, applied deliberately to each of Slack's realtime surfaces. Let's map each feature to the underlying mechanism.

The Core Insight: One Workspace, Many Channels

A Slack workspace is a container for channels. Each channel is an isolated stream of messages between a defined set of users. This maps directly onto the WebSocket channel abstraction.

When a user opens their Slack client:

  1. They connect to the WebSocket infrastructure
  2. They subscribe to one WebSocket channel per Slack channel they're a member of
  3. They subscribe to a private channel for personal notifications
  4. They join a presence channel for the workspace

Everything else flows from this foundation.

1. Channel Messages

The simplest part. When a user sends a message to #engineering, your server publishes it to the corresponding WebSocket channel. Every subscriber receives it immediately.

Server (Node.js)

import { ApinatorServer } from '@apinator/server';
import express from 'express';

const app = express();
const apinator = new ApinatorServer({
  appId: process.env.APINATOR_APP_ID,
  apiKey: process.env.APINATOR_API_KEY,
  apiSecret: process.env.APINATOR_API_SECRET,
});

app.post('/api/messages', express.json(), async (req, res) => {
  const { channelId, text, userId } = req.body;

  // Persist to database first
  const message = await db.messages.create({ channelId, text, userId });

  // Then broadcast to all channel subscribers
  await apinator.publish(`private-channel-${channelId}`, 'message.new', {
    id: message.id,
    text: message.text,
    userId: message.userId,
    createdAt: message.createdAt,
  });

  res.json({ message });
});

Client (JavaScript)

import { RealtimeClient } from '@apinator/sdk';

const client = new RealtimeClient({
  appKey: 'your-app-key',
  authEndpoint: '/api/auth/channel',
});

function joinChannel(channelId) {
  const channel = client.subscribe(`private-channel-${channelId}`);

  channel.bind('message.new', (data) => {
    appendMessage(data);
  });

  return channel;
}

// Subscribe to all the user's channels on load
userChannels.forEach((ch) => joinChannel(ch.id));

Private channels (private- prefix) require your server to sign the subscription — ensuring only authorized users can join.

2. Typing Indicators

Typing indicators are ephemeral. They don't need to be stored. They're sent directly from the typing client to other channel members using client events — no server roundtrip.

let typingTimeout;

messageInput.addEventListener('input', () => {
  channel.trigger('client-typing', {
    userId: currentUser.id,
    name: currentUser.name,
  });

  clearTimeout(typingTimeout);
  typingTimeout = setTimeout(() => {
    channel.trigger('client-stopped-typing', { userId: currentUser.id });
  }, 3000);
});

channel.bind('client-typing', (data) => {
  if (data.userId !== currentUser.id) {
    showTypingIndicator(data);
  }
});

channel.bind('client-stopped-typing', (data) => {
  hideTypingIndicator(data.userId);
});

client- prefixed events bypass your server entirely — the WebSocket infrastructure routes them directly from sender to channel members. This minimizes latency and eliminates load on your API servers.

Note: client events require an authenticated (private or presence) channel.

3. Online Presence

The workspace presence panel uses a presence channel. Every connected user is a member; joining and leaving trigger notifications to all other members.

const presenceChannel = client.subscribe(
  `presence-workspace-${workspaceId}`,
);

presenceChannel.bind('realtime:subscription_succeeded', ({ members }) => {
  renderOnlineMembers(members);
});

presenceChannel.bind('realtime:member_added', (member) => {
  setMemberOnline(member.id, member.info);
});

presenceChannel.bind('realtime:member_removed', (member) => {
  setMemberOffline(member.id);
});

Your channel auth endpoint attaches user info to the subscription:

app.post('/api/auth/channel', (req, res) => {
  const user = req.user;
  const { socket_id, channel_name } = req.body;

  const auth = apinator.authorizeChannel(socket_id, channel_name, {
    user_id: user.id,
    user_info: {
      name: user.name,
      avatar: user.avatarUrl,
      status: user.status,
    },
  });

  res.json(auth);
});

4. Direct Messages and Personal Notifications

Notifications — mentions, DMs, reactions — are delivered to a private channel specific to each user. No other user can subscribe to it.

const notificationChannel = client.subscribe(
  `private-user-${currentUser.id}`,
);

notificationChannel.bind('notification.mention', (data) => {
  showDesktopNotification(data);
  incrementBadgeCount();
});

notificationChannel.bind('notification.dm', (data) => {
  showDMBadge(data.fromUserId);
});

When someone mentions @alice in a channel, your server publishes to her personal notification channel in addition to the main channel:

if (mentionedUsers.includes(targetUser)) {
  await apinator.publish(`private-user-${targetUser.id}`, 'notification.mention', {
    channelId,
    messageId: message.id,
    text: message.text,
    fromUser: sender,
  });
}

5. Message Persistence and History

WebSockets handle delivery. Databases handle history.

When a user opens a channel or reconnects after being offline, they load past messages from your REST API — not the WebSocket. The WebSocket carries messages from the moment of subscription forward.

async function openChannel(channelId) {
  // 1. Load history from the database
  const history = await fetch(`/api/channels/${channelId}/messages`).then(r => r.json());
  renderHistory(history.messages);

  // 2. Subscribe to real-time updates
  const channel = joinChannel(channelId);

  return channel;
}

This separation of concerns keeps each layer simple: your database is the source of truth; WebSockets are the delivery mechanism for live updates.

The Complete Architecture

User A sends message
       ↓
POST /api/messages (your server)
       ↓
1. Save to database
2. apinator.publish('private-channel-engineering', 'message.new', payload)
       ↓
Apinator WebSocket infrastructure
       ↓
All subscribers of 'private-channel-engineering'
receive the 'message.new' event instantly

For typing indicators, the path is shorter — no server involved at all:

User A types
       ↓
channel.trigger('client-typing', data)
       ↓
Apinator routes directly to other channel members

What This Doesn't Cover

A real Slack clone needs more: message editing and deletion (publish update/delete events), reactions (same pattern as messages), file uploads (REST, then publish a message event), thread replies (nested channels or event data), and read receipts (presence + database tracking).

Each feature follows the same pattern: REST for persistence, WebSocket events for real-time delivery. The channel concept maps directly to Slack's product model. The infrastructure is the same for all of it — what changes is the event schema.