Iden Tumuhirwe
All writing

July 4, 2026 · 6 min read

What building a Slack clone taught me about Elixir

Elixir · Phoenix · Real-time

I've heard the pitch for Elixir a dozen times: lightweight processes, "let it crash," Phoenix Channels handle a firehose of WebSocket traffic without breaking a sweat. I'd never actually tested any of it myself, so I gave myself a weekend project with a clear bar to clear: rebuild the parts of Slack that make it feel alive. Messages appearing instantly, the green presence dot, "someone is typing," threads, reactions. The boring CRUD parts (users, workspaces, channels) are the same in any stack. I wanted to find out if the real-time parts were actually easier in Elixir, or if that's just something people say at conferences.

The result is kallyas/slack, a Phoenix backend and a separate React/TypeScript frontend, talking over Phoenix Channels for anything live and a plain JSON API for everything else.

Channels are just processes with a mailbox

The thing that took the longest to click, and then felt obvious in retrospect: a Phoenix Channel is not a magic WebSocket abstraction, it's a GenServer-ish process per client-per-topic. When a user's browser joins room:42, they get their own process on the server, holding whatever state that connection needs. Broadcasting to a topic means the PubSub layer walks the list of subscribed processes and sends each one a message. That's it. No shared connection pool to reason about, no manual fan-out logic.

Once I stopped thinking of channels as "sockets" and started thinking of them as "a process that represents this browser tab, right now," the rest of the design fell out pretty naturally.

Two topics, not one

My first pass had a single channel per chat room and pushed everything through it: messages, typing, unread counts, all of it. That falls apart the moment you want a sidebar: you'd have to join every channel in a workspace just to know which ones have unread messages, which means opening dozens of socket joins for something that should be cheap.

I split it into two topics instead:

  • room:<channel_id>, joined only while you're actually looking at that channel. Handles messages, edits, deletes, reactions, typing.
  • workspace:<workspace_id>, joined once per workspace, for the lifetime of the session. Carries a much lighter channel_activity event so the sidebar can light up an unread badge for a channel you're not currently viewing.
defp notify_workspace(socket, data) do
  channel_id = socket.assigns.channel_id

  SlackBackendWeb.Endpoint.broadcast("workspace:#{socket.assigns.workspace_id}", "channel_activity", %{
    channel_id: channel_id,
    author_id: data.user_id,
    mentioned_user_ids: mentioned_user_ids(channel_id, data.body)
  })
end

mentioned_user_ids is deliberately dumb right now. It just checks channel members against @username substrings in the body. It's not going to survive contact with someone named "al" being matched by every message that mentions "practical," but it was good enough to prove the badge mechanism works, and fixing the matching is a contained, boring problem for later.

Presence, for free

Phoenix.Presence tracks who's connected to a topic across every node in the cluster, and it does the CRDT-style merge/diff work so you don't have to. All I had to write was this:

def handle_info(:after_join, socket) do
  user = socket.assigns.current_user

  {:ok, _} =
    Presence.track(socket, to_string(user.id), %{
      user_id: user.id,
      username: user.username,
      display_name: user.display_name || user.username,
      online_at: System.system_time(:second)
    })

  push(socket, "presence_state", Presence.list(socket))
  {:noreply, socket}
end

The client gets a presence_state push on join and presence_diff events after that. I didn't write a single line of "remove this user from the online list when their socket dies." Presence notices the process going down and broadcasts the diff itself. Every version of this I've seen built by hand in other stacks ends up as Redis TTLs plus a background sweep job to catch the connections that died without saying goodbye, so watching it happen with zero extra code felt like it shouldn't have been that easy.

Auth: verify once, trust the socket

The JSON API and the WebSocket share the same JWT. UserSocket.connect/3 verifies the token once, at socket connect time, and stashes the user on the socket:

def connect(%{"token" => token}, socket, _connect_info) do
  with {:ok, user_id} <- UserAuth.verify_token(token),
       %{} = user <- Accounts.get_user(user_id) do
    {:ok, assign(socket, :current_user, user)}
  else
    _ -> :error
  end
end

Every channel after that (RoomChannel, WorkspaceChannel) reads socket.assigns.current_user and just does its own authorization check (is this user a member of this channel's workspace?) without re-verifying the token on every single message. It's a small thing, but it means the hot path (sending a message, reacting, typing) never touches the auth system again after the initial handshake.

The part that surprised me: dedup lives on the client

Broadcasts are at-least-once, not exactly-once, and I ran into this directly during testing on a flaky connection: a new_msg broadcast landed twice in the same render cycle. The fix isn't on the server; it's a one-line guard in the React hook that owns the channel:

chan.on("new_msg", (msg: Message) => {
  setMessages((prev) => (prev.some((m) => m.id === msg.id) ? prev : [...prev, msg]));
});

I went looking for a "right" way to make broadcasts idempotent server-side and mostly found articles talking me out of it. Trying to guarantee exactly-once delivery over a network is its own deep hole, and a dumb id check on the client is a few characters instead. Typing indicators get the same treatment from the other direction: the client sets a 3.5-second timeout on every typing event it receives and clears the "X is typing" state if a fresh one doesn't arrive in time, so a client that vanishes mid-type doesn't leave a ghost indicator on-screen forever.

What's actually running

Backend is Phoenix 1.7 on Bandit instead of Cowboy (fewer dependencies, and I wanted to see how it held up; no complaints so far), Ecto against Postgres, bcrypt_elixir for passwords. Frontend is React 19 + TypeScript + Vite, plain phoenix JS client for the socket, no real-time abstraction layer on top of it. REST covers workspaces, invites, channel membership, uploads; channels cover the four things that need to be instant: messages, presence, typing, reactions.

Where it's rough

Threads work but the count is not evented, it's parent_reply_count looked up on write:

broadcast!(socket, "thread_updated", %{
  message_id: message.parent_id,
  reply_count: parent_reply_count(message.parent_id),
  last_reply_at: data.inserted_at
})

Fine at the scale I've tested. I wouldn't want to defend that under real thread traffic without checking whether it's doing an extra round-trip per reply.

Mention detection needs actual word-boundary matching, not substring matching. Attachments upload fine but there's no virus scanning or size-based rejection beyond what the client enforces, which is not enforcement at all. And I still haven't tried it against more than one BEAM node, so all my "presence just merges across the cluster" enthusiasm above is theoretical for anything beyond a single instance. The plumbing (dns_cluster) is there, I just haven't turned it on.

None of that changes the answer to the original question, though. The real-time parts genuinely were less code than I expected going in, and the amount of it I didn't have to write (reconnect handling, presence bookkeeping, per-connection state) was the part that actually held up.