Build a Twitter clone on the BEAM
A step-by-step tutorial in Elixir + Francis — realtime, multi-user, and persistent, with zero external infrastructure.
Elixir runs on the BEAM — the virtual machine behind Erlang, built to run millions of tiny, isolated processes that talk by passing messages. That foundation makes realtime features (live feeds, chat, presence) things you get from the standard library, instead of bolting on Redis, a message broker, and a separate websocket service.
We'll build Chirp the way you'd actually build it at a keyboard: start with one route, get something working, then add the next piece. It stays in a single file nearly the whole way — only the GenServer earns its own. No prior BEAM knowledge assumed. The panel on the right is a running Chirp; post in it as you read.
"Twitter clone" is mostly the framing — what we're really building is a live chat: a shared, append-only feed that everyone watching sees update in realtime. If you know Phoenix's famous 15-minute LiveView Twitter clone, this is the same app built without Phoenix — so the BEAM primitives (processes, Registry, GenServer) are out in the open instead of behind LiveView.
By the end there are just two files:
chirp.ex— the Francis app. One module: it serves the page, owns the/socketWebSocket, and renders a chirp to HTML. Almost everything lives here.timeline.ex— the store. A GenServer holding every chirp in memory. It's the one piece that earns its own file.
Two BEAM built-ins do the heavy lifting and need no file of their own: Registry (pub/sub — who's connected) and the GenServer behind the Timeline (state — what's been said).
1 Meet Francis — the Sinatra of Elixir
Francis is a micro web
framework: think Sinatra (Ruby) or Flask (Python). You
use Francis and declare routes as little anonymous functions —
no controllers, no generators, no folders of boilerplate. Here's
essentially its whole surface area:
defmodule MyApp do
use Francis
get "/", fn _ -> %{hello: :world} end
get "/:name", fn %{params: %{"name" => name}} -> "hello #{name}" end
post "/", fn conn -> conn.body_params end
ws "/chat", fn
:join, socket -> {:reply, "welcome"}
{:received, msg}, _ -> {:reply, msg}
end
sse "/events", fn
:join, socket -> {:reply, %{id: socket.id}}
{:received, m}, _ -> {:reply, m}
end
unmatched fn _ -> "not found" end
end
That's the whole framework: a function per route. (We'll go all-in on
ws and skip sse here.) Francis ships a generator,
so starting a project is a few commands:
mix archive.install hex francis # one-time: installs the mix francis.* tasks
mix francis.new chirp # scaffold a project (no --sup — stay flat)
cd chirp
mix deps.get
mix francis.server # open http://localhost:4000
mix francis.new hands you a lib/chirp.ex
with use Francis and a sample route. The smallest useful
version is one module, one route — returning a string sends it straight
back as the response body:
defmodule Chirp do
use Francis
# A route is a verb + path + function. Whatever it returns is the response:
# a string becomes the body, a map becomes JSON.
get "/", fn _conn -> "<html>hello</html>" end
end
That underscore in _conn means "I'm intentionally ignoring
this argument." conn is the connection — a
struct holding everything about the request (path, params, headers,
cookies) plus the response you're building. This route doesn't need it yet.
mix francis.server and GET / says hello.
2 Your first realtime: a WebSocket echo
We're building a chat, so a chirp has to travel both ways — browser → server and back down to every browser. A WebSocket is a single connection that carries both directions, so we'll lean on it for everything. Start with the smallest possible version: echo whatever the browser sends straight back.
ws "/socket", fn
:join, _socket -> :noreply
{:received, msg}, _socket -> {:reply, msg} # bounce it straight back
{:close, _reason}, _socket -> :ok
end
Those three clauses are the three events a socket sees, pattern-matched in
the function head: :join (a literal atom — note the colon!)
when a browser connects, {:received, msg} when it sends, and
{:close, _} when it leaves. We just bounce msg
back. No page yet — prove it from the browser console:
const ws = new WebSocket("ws://localhost:4000/socket");
ws.onmessage = (e) => console.log("server said:", e.data);
ws.onopen = () => ws.send("hello from me");
You'll see server said: hello from me print right there. That
round trip is the whole idea: a socket is just a bidirectional text
pipe — you send bytes, {:received, msg} fires,
{:reply, msg} sends bytes back. No framework cleverness.
3 Give it a face — htmx
Now a real page, using htmx — a tiny library
that lets plain HTML talk over a WebSocket through attributes, no custom
JavaScript. Return this from your get "/" route:
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>chirp</title>
<script src="https://unpkg.com/[email protected]"></script>
<script src="https://unpkg.com/[email protected]/ws.js"></script>
</head>
<body>
<h1>chirp</h1>
<div hx-ext="ws" ws-connect="/socket">
<form ws-send>
<input name="chirp" autocomplete="off" placeholder="say something">
<button type="submit">chirp</button>
</form>
<ul id="feed"></ul>
</div>
</body>
</html>
hx-ext="ws"+ws-connect="/socket"— open a socket to our handler on load.ws-sendon the form — on submit, htmx serializes the form fields to JSON and sends them up the socket.<ul id="feed">— where chirps should land.
Submit a chirp and… nothing shows up. Open DevTools → Network → the
socket row → Messages, and you'll see your JSON go up and an
echo come back — but the page stays empty. The reason is the key htmx idea:
it treats every incoming message as HTML, and only acts on an
hx-swap-oob instruction telling it where to put
itself. Raw JSON has none, so it's dropped. Fix it by replying with a
fragment that addresses a spot on the page:
{:received, raw}, _socket ->
text = Jason.decode!(raw)["chirp"]
# An "OOB" fragment: it tells htmx WHERE to put itself — append into #feed.
{:reply, ~s(<div hx-swap-oob="beforeend:#feed"><li>#{text}</li></div>)}
hx-swap-oob="beforeend:#feed" means "append my contents to
#feed." htmx unwraps the <div>, drops the
<li> into the feed, and throws the wrapper away — so the
swap instruction must sit on a wrapper, not the
<li> itself. Now your chirp appears.
4 Everyone sees it — Registry pub/sub
Open a second tab: a chirp in one doesn't reach the other. Each socket replies only to itself. To fan a chirp out to everyone, the BEAM has pub/sub built in — Registry, from the standard library, no Redis, no broker. It's just a phone book of who's connected.
Where does a Registry live? It has to be a running process — and on the
BEAM, long-lived processes belong to a supervision tree.
use Francis already made your module an OTP
application: its start/2 boots a supervisor whose one
child is the Bandit web server. It's overridable, so we add the Registry
beside it:
# use Francis quietly made this module an OTP application with a start/2 that
# boots Bandit. It's overridable — so we override it to supervise a Registry
# alongside the web server.
def start(_type, _args) do
children = [
{Registry, keys: :duplicate, name: Chirp.PubSub},
{Bandit, plug: __MODULE__}
]
Supervisor.start_link(children, strategy: :one_for_one)
end
keys: :duplicate on that Registry line lets many
processes list themselves under the same key — a "topic." That's exactly
what pub/sub needs: many subscribers per channel.
That last line — Supervisor.start_link(children, strategy:
:one_for_one) — is quietly one of the BEAM's superpowers. A
supervisor is a process whose only job is to start its
children and watch them: if one crashes, it restarts it to a
clean, known state in microseconds. :one_for_one means a crash
stays contained — only the process that failed is restarted; the others
never notice.
This is the BEAM's "let it crash" philosophy — arguably
its single biggest idea, and one almost no other stack offers natively.
Most platforms wrap risky code in defensive try/catch and hope
nothing leaks. The BEAM bets the other way: processes are fully isolated,
sharing no memory, so when one hits an unexpected error you let it
die rather than nurse it along — and a supervisor revives it from
a known-good state. A crash can't cascade. Here, if our
Timeline ever died, the supervisor would restart just it while
the web server and Registry kept serving without a blip. (It'd come back
empty — which is exactly why persistence is the next thing you'd add.)
Other stacks reach for systemd, Kubernetes, or pm2 to restart the
whole app; the BEAM does it per-process, from the standard
library — which is how Erlang systems became famous for "nine nines" of
uptime.
With that in place, each socket subscribes on join, and a new chirp gets dispatched to every subscriber:
ws "/socket", fn
:join, _socket ->
# this socket's process joins the "chirps" topic. When the connection
# dies the process dies, and Registry drops it automatically — no cleanup.
Registry.register(Chirp.PubSub, :chirps, nil)
:noreply
{:received, raw}, _socket ->
text = Jason.decode!(raw)["chirp"]
html = ~s(<div hx-swap-oob="beforeend:#feed"><li>#{text}</li></div>)
# send the finished HTML to EVERY subscriber's process. Each one's
# handle_info turns it into a frame down its own socket.
Registry.dispatch(Chirp.PubSub, :chirps, fn subscribers ->
for {pid, _value} <- subscribers, do: send(pid, html)
end)
:noreply
{:close, _reason}, _socket ->
:ok
end
The trick that makes this work: in Francis, any message sent to a
socket's process is pushed straight down that socket as a frame. So
"broadcast to everyone" is literally "send the HTML to each
subscriber's process." Because this socket is a subscriber too, the author
sees the chirp through the same path as everyone else — one code path, no
special-casing. Two tabs now stay in sync. ~10 lines, no infrastructure.
5 A memory — the GenServer
Open a fresh tab and it's empty; refresh and everything's gone. Chirps only ever exist as in-flight messages — the app has no memory. Time for the BEAM's signature tool: a GenServer is a process that loops forever holding state and handling one message at a time. That "one at a time" is the magic — the state has a single owner, so there are no locks and no races. Everyone else just sends it a message.
A GenServer earns its own file (it's a distinct responsibility with its own callbacks), so this is the one module we split out:
defmodule Chirp.Timeline do
use GenServer
# client API — runs in the CALLER's process; just sends a message.
def start_link(_opts), do: GenServer.start_link(__MODULE__, [], name: __MODULE__)
def post(chirp), do: GenServer.cast(__MODULE__, {:post, chirp})
def list, do: GenServer.call(__MODULE__, :list)
# server callbacks — run INSIDE the GenServer's process; touch the state.
def init(_), do: {:ok, []} # start empty
def handle_cast({:post, chirp}, chirps), do: {:noreply, [chirp | chirps]}
def handle_call(:list, _from, chirps), do: {:reply, Enum.reverse(chirps), chirps}
end
The two-halves shape is the whole point. Two processes are involved: the caller (a WebSocket connection, say) and the Timeline process itself. They share no memory — the list of chirps exists only as state inside the Timeline process, which handles one message at a time. So the state has exactly one owner and every change is serialized: no locks, no races, ever. That guarantee is what everything below buys you.
The server callbacks — init,
handle_cast, handle_call — are the code that runs
inside that process to handle incoming messages. You never
call them directly. OTP invokes them for you when a message
arrives, threading the current state in and the new state out; treat them
as message handlers, not public functions. Calling handle_cast/2
from another module would just run it in your process against a
state you made up — it never touches the real thing, so it's meaningless.
The client API — post/1, list/0 —
is the public face, and each is a thin wrapper that sends exactly one
message. post/1 calls
GenServer.cast(__MODULE__, {:post, chirp}) (fire-and-forget);
list/0 calls GenServer.call(__MODULE__, :list)
(blocks for the reply). The send and the handler are two ends of one
message, paired by the tuple's shape: {:post, chirp} here,
matched by handle_cast({:post, chirp}, chirps) there.
You could skip the wrappers and call
GenServer.cast(Timeline, {:post, chirp}) from anywhere — it's
identical. You don't, for the usual encapsulation reasons: that
{:post, chirp} tuple is a private protocol you want defined in
one place (change it later without chasing down call sites), and
"Timeline is a GenServer" is an implementation detail callers shouldn't
depend on — swap it for ETS or a database and Timeline.post/1
never changes. Same discipline as calling a module's public function
instead of reaching into its internals. (And OTP holding the returned state
between messages is, finally, how the process "remembers.")
That [chirp | chirps] in handle_cast
is Elixir's cons syntax: it builds a new list with
chirp on the front of the existing chirps. Data is
immutable, so you never change the old list — you return a new one, and that
becomes the next state. Prepending is instant (O(1)), so new chirps go on
the front; list/0 flips them back with Enum.reverse
when reading.
Supervise it (before Bandit), then wire the socket to it:
children = [
{Registry, keys: :duplicate, name: Chirp.PubSub},
Chirp.Timeline, # <-- supervise the store, before Bandit
{Bandit, plug: __MODULE__}
]
:join, _socket ->
Registry.register(Chirp.PubSub, :chirps, nil)
# catch this newcomer up: re-send every stored chirp to just this socket
for html <- Chirp.Timeline.list(), do: send(self(), html)
:noreply
{:received, raw}, _socket ->
text = Jason.decode!(raw)["chirp"]
html = ~s(<div hx-swap-oob="beforeend:#feed"><li>#{text}</li></div>)
Chirp.Timeline.post(html) # remember it
Registry.dispatch(Chirp.PubSub, :chirps, fn subscribers ->
for {pid, _value} <- subscribers, do: send(pid, html) # tell everyone now
end)
:noreply
On join we replay the backlog to the newcomer; on post we store the chirp and broadcast it. Notice we're handing the Timeline finished HTML strings for now — and the GenServer doesn't care what it stores, which is itself a lesson. (We'll fix that next.) Now a fresh tab shows the whole history.
Why send(self(), …) on join? The replay runs
inside this socket's own process, so self() is this
connection. A join can't "return" a whole backlog — but (from the last
step) any message sent to a socket's process is pushed down as a frame. So
we replay by sending each old chirp to ourselves: the same delivery path a
broadcast uses, just aimed at the one client catching up.
One honest limit: the state lives in the process's memory,
so Ctrl-C still wipes it. Surviving a restart is a separate
problem — see "What's next" at the end.
6 Store the words, not the picture
Look at what the Timeline is actually holding right now. Because we've been handing it finished HTML, its state is a list of markup blobs:
# what the Timeline holds RIGHT NOW — a finished HTML blob per chirp:
[
"<div hx-swap-oob=\"beforeend:#feed\"><li>hello</li></div>",
"<div hx-swap-oob=\"beforeend:#feed\"><li>first!</li></div>"
]
# what we actually want it to hold — just the words:
["hello", "first!"]
Two problems with the blobs. The look is welded to the data — restyle the
markup and every old chirp still replays its old HTML. And we
interpolate user text raw, so a chirp of <script> would
run in every browser. The fix is the oldest rule in web apps:
store the words, render them on demand.
So move the markup into li/1 and treat it as a little
component: hand it one chirp, get back its HTML — escaped,
and built fresh every time it's shown.
# text -> escaped HTML. Public (def, not defp) so the relocated ws handler can
# reach it as a fully-qualified call: Chirp.li/1.
def li(text) do
~s(<div hx-swap-oob="beforeend:#feed"><li>#{esc(text)}</li></div>)
end
defp esc(value), do: Plug.HTML.html_escape(value)
esc/1 (Plug.HTML.html_escape) turns
< into <, so injected markup renders as
plain text. Now store the raw text and call the component only at send time:
:join, _socket ->
Registry.register(Chirp.PubSub, :chirps, nil)
for text <- Chirp.Timeline.list(), do: send(self(), Chirp.li(text))
:noreply
{:received, raw}, _socket ->
text = Jason.decode!(raw)["chirp"]
html = Chirp.li(text) # the VIEW, drawn from the data
Chirp.Timeline.post(text) # store the words, not the markup
Registry.dispatch(Chirp.PubSub, :chirps, fn subscribers ->
for {pid, _value} <- subscribers, do: send(pid, html)
end)
:noreply
The Timeline now holds words; the li/1 component is the single
place they become markup. The payoff is visible: add a 🐦 to
the component, open a fresh tab, and even old chirps come back
restyled — they were never frozen as HTML. And <script>
now shows as harmless text.
All together
The whole app, end to end: a live, multi-user chat in two short files and not a single piece of external infrastructure — no database, no Redis, no broker. That's the BEAM's pitch in one small app.
Isn't this just LiveView? Same family — a socket per client, server-rendered HTML pushed to update the DOM. But LiveView gives each client its own stateful process and ships minimal diffs via its own JS runtime; here the per-connection process is a dumb relay, state lives in one shared GenServer, and generic htmx swaps whole fragments. This is the hand-rolled version — the primitives LiveView automates, out in the open.
defmodule Chirp do
use Francis
def start(_type, _args) do
children = [
{Registry, keys: :duplicate, name: Chirp.PubSub},
Chirp.Timeline,
{Bandit, plug: __MODULE__}
]
Supervisor.start_link(children, strategy: :one_for_one)
end
get "/", fn _conn ->
"""
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>chirp</title>
<script src="https://unpkg.com/[email protected]"></script>
<script src="https://unpkg.com/[email protected]/ws.js"></script>
</head>
<body>
<h1>chirp</h1>
<div hx-ext="ws" ws-connect="/socket">
<form ws-send>
<input name="chirp" autocomplete="off" placeholder="say something">
<button type="submit">chirp</button>
</form>
<ul id="feed"></ul>
</div>
</body>
</html>
"""
end
ws "/socket", fn
:join, _socket ->
Registry.register(Chirp.PubSub, :chirps, nil)
for text <- Chirp.Timeline.list(), do: send(self(), Chirp.li(text))
:noreply
{:received, raw}, _socket ->
text = Jason.decode!(raw)["chirp"]
html = Chirp.li(text)
Chirp.Timeline.post(text)
Registry.dispatch(Chirp.PubSub, :chirps, fn subscribers ->
for {pid, _value} <- subscribers, do: send(pid, html)
end)
:noreply
{:close, _reason}, _socket ->
:ok
end
unmatched fn _ -> "not found" end
def li(text) do
~s(<div hx-swap-oob="beforeend:#feed"><li>#{esc(text)}</li></div>)
end
defp esc(value), do: Plug.HTML.html_escape(value)
end
defmodule Chirp.Timeline do
use GenServer
def start_link(_opts), do: GenServer.start_link(__MODULE__, [], name: __MODULE__)
def post(chirp), do: GenServer.cast(__MODULE__, {:post, chirp})
def list, do: GenServer.call(__MODULE__, :list)
def init(_), do: {:ok, []}
def handle_cast({:post, chirp}, chirps), do: {:noreply, [chirp | chirps]}
def handle_call(:list, _from, chirps), do: {:reply, Enum.reverse(chirps), chirps}
end
What's next
The one gap we left open: state lives in the GenServer's memory, so a
restart forgets everything. The classic BEAM fix needs no database — keep
the feed in an ETS table (a built-in in-memory store with
fast concurrent reads) and periodically dump it to a file
with :erlang.term_to_binary/1, reloading on boot. That
:erlang. prefix is you reaching straight into the underlying
Erlang VM — decades of battle-tested OTP, no wrapper. A natural next
session.