System design

Wavelink, a real chat app under system-design/chat-demo

This one isn't a story about a made-up company. It's a real Elixir/Phoenix backend and React frontend, living at demo/chat-demo in this site's own repo, built specifically to make the design decisions below checkable in code instead of taking my word for them.

Topic
System design, working code
Source
demo/chat-demo in this repo — backend/ (Elixir, Phoenix), ui/ (React, Vite)
Updated
August 2026

Covered


What it is

Wavelink is a small WhatsApp-shaped chat app: register a username, see who else is online, send messages, watch the tick go sent → delivered → read. No password, no server-side auth beyond a unique username — that's a deliberate cut, not an oversight, so the project stays about message delivery rather than identity.

The backend is Phoenix, chosen specifically for Channels and Presence, the two pieces that make "push a message to whichever open connection this user currently has" a library call instead of a project. The frontend is a plain React SPA that talks to it over the official phoenix JS client, no state library, one hook.

Functional requirements

Non-functional requirements

Features actually built

Architecture

One Phoenix node, one socket per client carrying three channels: a per-user InboxChannel (the rooms list), a ConversationChannel joined only for whichever conversation is actually open, and a shared DirectoryChannel for the contact list. A DM and a group both live behind the same ConversationChannel — see Group chat below for why that unification, not a separate group system, is the actual design decision here. Storage, the user directory, and conversation membership all sit behind a small behaviour so the same code path runs against ETS in dev/test or DynamoDB in prod — picked by one runtime config value, not a different code branch.

React SPA (ui/) phoenix JS client, one socket Phoenix endpoint Bandit, UserSocket.connect/3 assigns user_id, no password InboxChannel topic user:<id>, rooms list DirectoryChannel topic directory:lobby, shared Phoenix.PubSub + Presence local adapter, one node Store / Directory behaviour — Memory (ETS) or Dynamo, by config
This shows the shape before group chat: two channels on one socket. Group chat adds a third, ConversationChannel, joined per open conversation rather than per user — see the diagram in Group chat below. The behaviour boundary here is what lets dev run on nothing but ETS while prod points the same code at DynamoDB.

A message, end to end

The write-before-push ordering is the one decision the rest of the messaging feature hangs off: a message exists durably before the server ever tries to hand it to a live connection, so "recipient is offline" isn't a special case, it's just a push that doesn't happen yet.

A: send_message InboxChannel.handle_in Store.put_message — write first ack to A only (id swap) broadcast user:A (other tabs) broadcast user:B B auto-acks "delivered" relayed to A's topic "read" fires the same relay path, only once B's chat window for A is the one on screen
Every ack — sent, delivered, read — travels the same channel/broadcast path as the message itself. No separate acknowledgment system.

Group chat

The design decision here isn’t “add a groups feature”, it’s that a DM stopped being its own special case. Wavelink.Conversations resolves both a DM (an id deterministic from the two usernames, sorted, created lazily the first time either side opens it) and a group (an explicit id, handed out at creation) down to one shape: a conversation with members. Everything downstream — the message store, the channel, the read state — only ever sees that one shape.

That unification is what made fan-out the real problem to solve, not membership management. The old per-recipient store wrote one row per recipient per message — fine at two, not at two hundred. The messages table is now keyed by conversation_id instead: one row per message, regardless of member count. ConversationChannel, topic conversation:<id>, is what every member’s every open tab joins directly, so Phoenix PubSub fans the broadcast out to N sockets on the way out instead of the write side fanning out to N rows on the way in.

Store.put_message one row, keyed by conversation_id broadcast conversation:<id> → "message" member A, every open tab member B, every open tab member C, every open tab also, for every member broadcast user:<member> → "conversation_touched" InboxChannel: preview + unread update live
The message itself is written once and pushed once, to whichever members are actually connected. The inbox update is the cheap part — a small event, not a write — sent to every member regardless of whether they have this conversation open.

Read state follows the same shape either way: a (conversation, user) cursor (last_delivered_id / last_read_id), not a flag per message per recipient. A DM’s tick and a group’s “Read 3/4” are the same comparison — message id against the other members’ cursors — run over one member instead of several. The tradeoff, named rather than hidden: a group’s tick only ever reflects the slowest member, the same convention WhatsApp uses, not a per-member breakdown.

What this still doesn’t solve: Phoenix.PubSub and Presence are still the local, single-node adapter (see below), so a broadcast to a very large group is still N pushes from one BEAM node’s one channel process. Group chat made that ceiling more visible, it didn’t raise it — horizontal scale is still the infra/modules/scale work named but not built, see below.

Complexity worth naming

Tech stack

Other things worth knowing


All system design notes · Home