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.

Browser chirp.ex one Francis module · page + WebSocket + render GET / · ws /socket Timeline GenServer · the chirps Registry who's connected store · replay subscribe · fan-out
One Francis module, leaning on two BEAM built-ins: a GenServer that remembers, a Registry that broadcasts.

By the end there are just two files:

  • chirp.exthe Francis app. One module: it serves the page, owns the /socket WebSocket, and renders a chirp to HTML. Almost everything lives here.
  • timeline.exthe 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:

the shape of a Francis app
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:

set up the project
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:

lib/chirp.ex
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.

add to Chirp
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:

paste in DevTools → 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:

the page
<!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-send on 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:

the :received clause
{: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:

override start/2 in Chirp
# 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:

the WebSocket handler
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:

lib/chirp/timeline.ex
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 callbacksinit, 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 APIpost/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.

connection connection connection Timeline one message at a time state: list of chirps messages (queued) post (cast) list (call)
One mailbox, one message at a time — so state changes never race.

Supervise it (before Bandit), then wire the socket to it:

the children list
children = [
  {Registry, keys: :duplicate, name: Chirp.PubSub},
  Chirp.Timeline,                     # <-- supervise the store, before Bandit
  {Bandit, plug: __MODULE__}
]
the WebSocket handler
: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:

the Timeline's state today — vs. what we want
# 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.

add to Chirp
# 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 &lt;, so injected markup renders as plain text. Now store the raw text and call the component only at send time:

the WebSocket handler
: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.