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.
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.
POST /api/register
does an atomic conditional write (DynamoDB's attribute_not_exists(username)
in prod, ETS insert_new in dev) instead of check-then-write, so two
clients can't both claim the same name. Re-registering an existing name isn't an
error, it's a resume.directory:lobby topic so every connected client's contact list updates
without a refresh.last_delivered_id / last_read_id
pair; acking a whole backlog after being offline moves that cursor once, not once per
message. A DM's tick and a group's "Read 3/4" are both read straight off those cursors.GET /healthz, written for an ALB target
group that doesn't exist yet — see below.
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.
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.
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.
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.
"<ms-timestamp>-<per-node monotonic integer>" is sortable
and effectively unique with no shared sequence, no lock, and no coordination between
requests — it doubles as the DynamoDB sort key.Directory.register/1 use an atomic
conditional insert rather than "look it up, then write if free," which is the gap
where two simultaneous registrations for the same name would otherwise both
succeed.Wavelink.Store and Wavelink.Directory are each an Elixir
@behaviour with a Memory and a Dynamo
implementation. Swapping one for the other is a config value in
config/runtime.exs, not an if env == :prod scattered
through the business logic.Memory
store is a single GenServer-owned ETS table, and its own moduledoc calls that out as
fine for a demo's traffic and not a claim about production concurrency. Presence and
PubSub both run on Phoenix's local, single-node adapter — real horizontal
scale would need a distributed PubSub adapter and clustered Erlang nodes, neither of
which is configured.DNSCluster child process starts on boot, reading a
DNS_CLUSTER_QUERY env var that nothing in the repo ever sets —
the multi-node path exists in code but has nowhere real to point yet.String.to_atom/1 rather than the safer to_existing_atom/1,
normally a way to let a client grow the atom table unbounded. It's safe here only
because a pattern-match guard upstream already closes the input to exactly two
literal strings, "delivered" and "read" — and the
code says so, rather than leaving that safety argument implicit.ex_aws /
ex_aws_dynamo for the DynamoDB-backed store. No Ecto, no Postgres, no
LiveView — it's a JSON API plus Channels, with the UI as a fully separate SPA.phoenix npm client for the socket. No router, no Redux/Zustand —
view state lives in one hand-rolled hook lifted into App.tsx. Linted with
oxlint instead of ESLint. Plain CSS, no Tailwind.infra/ has the shape of a Terraform layout
— modules/core, groups, media,
scale — but no .tf files exist yet; the conversations
and memberships tables group chat needs are named in config
(DYNAMO_CONVERSATIONS_TABLE, DYNAMO_MEMBERSHIPS_TABLE) the
same way the messages table always was, still waiting on
modules/groups. See below.DYNAMO_MESSAGES_TABLE,
ALLOWED_ORIGINS, an example origin of
chat-demo.rishavraj.info, AWS_REGION defaulting to
ap-south-1) sketch an ALB-plus-DynamoDB deployment that hasn't been
written in Terraform yet, and there's no Dockerfile or CI pipeline either. The
/healthz endpoint exists specifically for a target group that doesn't
exist.groups infra module now matches real code
— see Group chat above. Attachments are handled
too, not by an upload endpoint inside this app but by a separate standalone service,
media-service, that Wavelink’s backend calls
— see its own write-up for why that’s a different service rather than a
module in here. scale is still just a directory name: no clustered
PubSub, no second BEAM node.backend/lib carries a moduledoc explaining the
"why," and several point back at this write-up by name — the two were built
together, not one after the other.