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, a minimal Twitter-style feed, and meet the BEAM's building blocks one at a time — no prior BEAM knowledge assumed. The panel on the right is the finished app; 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, ETS) are out in the open instead of behind LiveView.

Browser site_web.ex the Francis app — both approaches approach 1 · SSE+POST — /chirps + /stream approach 2 · WebSocket — /socket timeline.ex the store · every chirp pubsub.ex the broadcaster · pub/sub every browser · live save · read each new chirp push live
One Francis module holding both approaches, over a shared store → broadcaster → every-browser core.

What each file is responsible for:

  • site_web.exthe Francis app. The one module that uses Francis. It holds both approaches as two route-groups: an HTTP-POST + SSE-stream pair (approach 1) and a single WebSocket (approach 2) — a real app picks one; Chirp keeps both to compare. Everything below is shared, plain modules.
  • chirp.exthe data. One chirp, as a struct.
  • timeline.exthe store. Keeps every chirp (in memory, then ETS + a dump file). The closest thing to a database.
  • pubsub.exthe broadcaster. Pub/sub (via Registry) that pushes each new chirp out to every live connection. It doesn't hold chirps — that's the store. Think "the loudspeaker," not "the archive."
  • render.exthe view. Turns a chirp into its HTML.
  • identity.exthe handle. Gives each visitor a throwaway name.
  • application.exthe boot. Starts and supervises everything: the registry, the store, and the web servers.

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

Five ideas, and that's the framework:

  • get / post — HTTP routes. Whatever the function returns becomes the response: a map turns into JSON, a string into the body.
  • "/:name" — a path parameter, handed to you in params.
  • ws — a WebSocket handler. You pattern-match the lifecycle (:join, {:received, msg}) and reply.
  • sse — a Server-Sent Events stream (server → browser, one way).
  • unmatched — the catch-all, i.e. your 404.

Under the hood use Francis turns the module into a plain Bandit web server. No magic runtime — just a function per route.

2 Your first route

The smallest possible app is one module and one route. Returning a string sends it straight back as the response body:

lib/chirp/web.ex
defmodule Chirp.Web 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 -> "Hello, Chirp!" end
end

Every route function receives one argument, conn — the connection. It's a struct (a Plug.Conn) holding everything about the request — path, query and form params, headers, cookies — plus the response you're building up. This route doesn't need any of it, so we write _conn: the leading underscore is Elixir's way of saying "I'm intentionally ignoring this argument." We'll actually reach into conn in a moment, to read the posted chirp and the reader's cookie.

Start it with mix run --no-halt and GET / says hello. From here on, every piece we add is a small function like this one.

3 A chirp is just data

Elixir is functional: no classes, no ORM. A chirp is a plain struct — an immutable map with known fields. We declare the shape, plus a constructor that fills in an id and a timestamp:

lib/chirp/chirp.ex
defmodule Chirp.Chirp do
  # A struct is an immutable map with a fixed set of keys. @enforce_keys
  # means you can't build a %Chirp{} unless all four are present.
  @enforce_keys [:id, :handle, :text, :at]
  defstruct [:id, :handle, :text, :at]

  # A plain constructor function. `Chirp.new("ada", "hello")` hands back a
  # fresh %Chirp{} — there are no classes or `new` keyword in Elixir.
  def new(handle, text) do
    # %__MODULE__{...} builds a struct of THIS module. __MODULE__ is a
    # shorthand for the current module's name (here Chirp.Chirp), so this is
    # the same as writing %Chirp.Chirp{...} — without repeating the name.
    %__MODULE__{
      id: 8 |> :crypto.strong_rand_bytes() |> Base.encode16(),  # random id
      handle: handle,
      text: text,
      at: System.system_time(:second)                          # unix time
    }
  end
end

A note on @spec: real Elixir often carries @spec lines — optional type annotations used by the Dialyzer checker and the docs. They're good practice but you don't need them to run anything, so we've left them out of these teaching snippets to keep them lean. You'll see them in the full files at the end.

4 Remembering chirps — a GenServer

On most stacks, "remember something between requests" means reaching for a database immediately. On the BEAM you can start simpler. A GenServer is a process that owns some state and handles one message at a time — a tiny in-memory server. Ours holds the list of recent chirps; it starts empty and lives as long as the app runs:

lib/chirp/timeline.ex — the GenServer callbacks
# `use GenServer` brings in the process plumbing; we fill in the callbacks.

# The starting state: an empty list of chirps.
def init(_), do: {:ok, []}

# Handle a :list request — reply with the chirps, leave state unchanged.
# (The 2nd element of the tuple is the reply, the 3rd is the next state.)
def handle_call(:list, _from, chirps) do
  {:reply, chirps, chirps}
end

# Handle a :post request — build the chirp, tell every connected client,
# then reply with it and store the new state (prepended, capped at 100).
def handle_call({:post, handle, text}, _from, chirps) do
  chirp = Chirp.new(handle, text)
  PubSub.broadcast(chirp)
  {:reply, chirp, Enum.take([chirp | chirps], 100)}
end

Because the process stays alive, that state survives across requests for free — no database needed to get going. We wrap the raw message-passing in a friendly API so the rest of the app just calls functions:

lib/chirp/timeline.ex — the public API
# Friendly wrappers so callers never write `GenServer.call` by hand.
# `__MODULE__` is this module's own name — we started the process under it,
# so it doubles as the address to send messages to.

# Read the current feed.
def list, do: GenServer.call(__MODULE__, :list)

# Save a chirp (silently ignoring blank text).
def post(handle, text) do
  case String.trim(text || "") do
    "" -> :ignore
    text -> GenServer.call(__MODULE__, {:post, handle, text})
  end
end

A word on pattern matching. Elixir leans on it instead of if/switch: you write several clauses and the one whose shape matches the data runs, binding variables as it goes. Above, a call carrying :list runs the first handle_call, while a {:post, handle, text} tuple runs the second — pulling handle and text out of the tuple in the process. The case does the same: an empty string matches "", anything else matches text. You'll see this shape-matching everywhere below — including the :join / {:received, msg} clauses in the realtime handlers.

web request web request web request Timeline one message at a time state: list of chirps messages (queued) reply to caller PubSub.broadcast
One mailbox, one message at a time — so state changes never race.

One catch worth naming now: it's in memory only, so a restart wipes the feed. We'll fix that in the last step — but it carries us a long way first.

5 Turning a chirp into HTML

The browser wants HTML, not a struct. Francis ships ~E, an EEx template sigil that auto-escapes anything you interpolate, so user text can't inject markup. One chirp becomes one list item:

lib/chirp/render.ex
# ~E is Francis's HTML sigil. Like Phoenix's ~H it auto-escapes every
# interpolation, so a chirp containing "<script>" can't inject markup.
def li(chirp) do
  assigns = %{handle: chirp.handle, text: chirp.text}

  ~E"""
  <li class="chirp"><span class="handle">@<%= @handle %></span> <span class="text"><%= @text %></span></li>
  """
  |> flatten()  # collapse to one line (SSE frames can't contain newlines)
end

@handle and @text come straight from the chirp, escaped on the way in. Server-rendered HTML, no client framework.

~E is for snippets; whole pages have a companion. ~E (from francis_htmx) is great for a fragment like this. For full pages from files, Francis pairs with francis_template: use FrancisTemplate then render(conn, "page.html.eex", assigns), with layouts and pluggable engines. The page you're reading is rendered by it — see site_web.ex in the final section.

6 Posting a chirp

Time to accept input. The route just records the chirp — it returns nothing to swap, because (as we'll see next) the new chirp arrives over a live stream instead:

lib/chirp/site_web.ex
# Ingest only: record the chirp and return nothing to swap. The new chirp
# reaches everyone over the live stream (next step), not via this response.
post("/chirps", fn conn ->
  Timeline.post(conn.params["handle"] || "anon", conn.params["chirp"])
  ""
end)

The form uses htmx, a tiny library that lets plain HTML do AJAX through attributes — no custom JavaScript:

the compose form — htmx
<!-- htmx: post the form over AJAX; hx-swap="none" means "don't replace
     anything with the response" — the live stream paints the chirp. -->
<form hx-post="/chirps" hx-swap="none" hx-on::after-request="this.reset()">
  <input type="hidden" name="handle" value="<%= @handle %>" />
  <input name="chirp" placeholder="What's happening?" required />
  <button type="submit">Chirp</button>
</form>

7 Telling everyone — Registry pub/sub

A new chirp has to reach every connected reader, not just the one who posted it. The BEAM has pub/sub built in: Registry, from the standard library — no Redis, no broker. Each connected client is a process that subscribes; broadcasting fans a message out to all of them:

lib/chirp/pubsub.ex — subscribe
# Each connected client calls this to join a Registry group (:ws or :sse).
# Registry is Elixir's built-in pub/sub — no Redis, no external broker.
def subscribe(kind), do: Registry.register(@registry, kind, nil)
lib/chirp/pubsub.ex — broadcast
# Fan one chirp out to every subscriber. `dispatch` runs our function with
# the list of subscriber processes; we send each process a message.
def broadcast(chirp) do
  Registry.dispatch(@registry, :ws, fn subscribers ->
    html = Render.oob(chirp)                              # WS: finished HTML
    for {pid, _} <- subscribers, do: send(pid, html)
  end)

  Registry.dispatch(@registry, :sse, fn subscribers ->
    for {pid, _} <- subscribers, do: send(pid, {:chirp, chirp})  # SSE: struct
  end)
end

Timeline.post/2 calls PubSub.broadcast/1 after saving, so a single post reaches everyone. The two branches — one for WebSocket clients, one for SSE — exist because the transports deliver slightly differently; that's the next two steps.

8 Streaming down — Server-Sent Events

There are two ways to make the feed live, and we'll build both. Here they are side by side before we dive in — same core underneath, different pipe:

SSE + POST Browser Server POST (http, up) SSE (stream, down) two channels WebSocket Browser Server /socket (both ways) one socket
Two ways to go realtime — both feeding the same Timeline + PubSub core.

SSE is a one-way pipe: the server pushes events, the browser listens. It can't carry the chirp up — that's what the HTTP POST from step 6 was for. On connect we subscribe and replay the backlog; each broadcast becomes a named chirp event:

lib/chirp/site_web.ex
# sse/2 declares a Server-Sent Events stream. We pattern-match its lifecycle.
sse("/stream", fn
  # On connect: subscribe, then replay the backlog oldest-first (so each
  # "prepend" leaves the newest chirp on top).
  :join, _socket ->
    Chirp.PubSub.subscribe(:sse)
    Chirp.Timeline.list() |> Enum.reverse() |> Enum.each(&send(self(), {:chirp, &1}))
    :noreply

  # A broadcast arrives here as a message — render it and frame it as a
  # named "chirp" event that the browser's htmx is listening for.
  {:received, {:chirp, chirp}}, _socket ->
    {:reply, %{event: "chirp", data: Chirp.Render.li(chirp)}}
end)

A ws/sse handler is the realtime cousin of an HTTP route. Where a get/post function gets a conn, this one is handed a socket — the state of this one live connection — alongside a lifecycle message you pattern-match: :join when it opens, {:received, msg} for something coming in, and {:close, _} when it ends. We don't need the socket's details here, so again it's _socket.

htmx's SSE extension does the browser-side wiring — connect, and prepend each event:

the live feed — htmx + SSE
<!-- htmx sse extension: open /stream and prepend each "chirp" event here -->
<ul id="feed" hx-ext="sse" sse-connect="/stream" sse-swap="chirp" hx-swap="afterbegin"></ul>

9 Two-way — a WebSocket

A WebSocket is a single bidirectional socket, so it carries chirps both directions — no separate HTTP post. On :join we subscribe and replay; an inbound message (the form, sent as JSON by htmx) becomes a Timeline.post. The live panel on the right is exactly this:

lib/chirp/site_web.ex
# ws/2 declares one bidirectional WebSocket.
ws("/socket", fn
  # On connect: subscribe and replay the backlog as finished HTML.
  :join, _socket ->
    Chirp.PubSub.subscribe(:ws)
    Chirp.Timeline.list() |> Enum.reverse() |> Enum.each(&send(self(), Chirp.Render.oob(&1)))
    :noreply

  # An inbound message is the form, sent up the socket as JSON by htmx —
  # decode it and post it. No separate HTTP route needed.
  {:received, raw}, _socket ->
    %{"chirp" => text} = msg = Jason.decode!(raw)
    Chirp.Timeline.post(msg["handle"] || "anon", text)
    :noreply
end)

With WebSockets the server sends finished HTML straight to the browser, where htmx swaps it in out of band — which is where one small gotcha lives:

lib/chirp/render.ex — the OOB carrier
def oob(chirp) do
  assigns = %{handle: chirp.handle, text: chirp.text}

  # The gotcha: hx-swap-oob lives on a wrapper <div>, NOT on the <li>. htmx's
  # "swap into a selector" form prepends the wrapper's CHILDREN and throws the
  # wrapper away — put it on the <li> and you'd lose the <li> (and its styling).
  ~E"""
  <div hx-swap-oob="afterbegin:#feed"><li class="chirp"><span class="handle">@<%= @handle %></span> <span class="text"><%= @text %></span></li></div>
  """
  |> flatten()
end

10 Who's posting? — throwaway identity

No login, but every chirp still needs a name. We mint a random handle once per browser and stash it in a cookie; open two windows and they post as two different users:

lib/chirp/identity.ex
# Return {conn, handle}. If there's no "handle" cookie yet, mint a random one
# and set it (good for 24h); otherwise reuse whatever the browser sent.
def ensure(conn) do
  conn = Plug.Conn.fetch_cookies(conn)

  case conn.cookies["handle"] do
    nil ->
      handle = random()  # e.g. "slyfinch21"
      {Plug.Conn.put_resp_cookie(conn, "handle", handle, max_age: 86_400), handle}

    handle ->
      {conn, handle}
  end
end

The handle rides along in a hidden form field, so both the HTTP post and the WebSocket use the exact same mechanism.

11 Surviving restarts — ETS + a dump file

Our GenServer list vanishes on restart. Two more BEAM tools fix that without a database. ETS is an in-memory table with fast, concurrent reads; we keep the live feed there so reads don't queue behind the writer:

lib/chirp/timeline.ex — reading from ETS
# ETS is an in-memory table built into the BEAM, with fast concurrent reads.
# We keep the whole feed under one key and read it straight from the table —
# no message to the GenServer, so reads never queue behind a write.
def list do
  case :ets.lookup(@table, @key) do
    [{@key, chirps}] -> chirps
    [] -> []
  end
end

Those :ets and :erlang calls are Erlang. A module name that's a plain atom — :ets, :erlang, :crypto (step 3), :rand — is an Erlang module. Elixir and Erlang share one VM, so you can reach straight into Erlang's standard library with no wrapper or binding: :ets.lookup/2, :erlang.term_to_binary/1, and so on are just function calls. Decades of battle-tested OTP, available as-is.

For durability we simply dump the list to a file: load it on boot to warm the table, then write it back on a timer (and once more on a clean shutdown):

lib/chirp/timeline.ex — load on boot
def init(_) do
  # trap_exit so a clean shutdown still reaches terminate/2 for a final save.
  Process.flag(:trap_exit, true)
  # :protected = only this process writes; any process may read concurrently.
  :ets.new(@table, [:named_table, :set, :protected, read_concurrency: true])
  # Warm the table from the dump file so a restart doesn't start empty.
  :ets.insert(@table, {@key, load_dump()})
  schedule_flush()
  {:ok, %{}}
end
lib/chirp/timeline.ex — flush to disk
# Every 10 seconds: write the feed to disk, then schedule the next flush.
# This timer — not the shutdown save — is the real safety net against crashes.
def handle_info(:flush, state) do
  dump(list())
  schedule_flush()
  {:noreply, state}
end

# Save and restore are one line each — no DETS, no database.
defp dump(chirps), do: File.write(@dump_path, :erlang.term_to_binary(chirps))
ETS table the live feed chirps.dump on disk load on boot flush · 10s Timeline single writer writes SSE / WS readers concurrent reads in-memory speed · survives restarts
ETS holds the feed; the dump file is just the backup.

That's the classic "ETS working set + file backup" pattern — in-memory speed plus survive-a-restart durability, no DETS or database ceremony. The honest caveat: a hard crash loses whatever changed since the last flush, so the periodic timer is the real safety net. And the whole upgrade touches only timeline.ex — the transport and render layers don't change at all.

All together

Here's the actual running app, end to end — the same source that serves this page. site_web.ex is the Francis app (both approaches as route-groups); the rest are the shared, single-concern modules. The write paths also run light rate-limiting via Chirp.Guard, and a couple of files that exist only to render this tutorial (tutorial.ex, highlight.ex) are left out.

defmodule Chirp.SiteWeb do
  @moduledoc """
  The Francis app: one module, one Bandit listener, one port. This is the only
  module that `use`s Francis — everything else is a plain module.

  It holds BOTH realtime approaches as two route-groups over the same core
  (the `Timeline` store, the `PubSub` broadcaster, the `Render` view):

    * `GET  "/"` — the page you're reading (a live, WebSocket-backed timeline on
      the right, rendered by `Chirp.Tutorial`).
    * `POST "/chirps"` + `GET "/stream"` — **approach 1: SSE + POST.** A chirp goes
      up over an ordinary HTTP POST; the feed streams back down over Server-Sent
      Events.
    * `WS "/socket"` — **approach 2: a single WebSocket** carrying chirps both ways.

  A real app would pick one approach; Chirp keeps both so you can compare them
  side by side. The two write paths also run light guardrails (`Chirp.Guard`) —
  a rate limit and a length/blank check — since this is a public demo.
  """
  use Francis
  # File-based templates (priv/templates/*.html.eex) via francis_template — this
  # very page is rendered by it. `use FrancisTemplate` imports `render/2,3,4`.
  use FrancisTemplate

  alias Chirp.{Identity, Tutorial, Timeline}

  # The tutorial page. Identity is read/minted here (same cookie mechanism as
  # both transports), then `render/3` renders priv/templates/tutorial.html.eex
  # (wrapped in layout.html.eex) with the assigns Chirp.Tutorial gathers.
  get("/", fn conn ->
    {conn, handle} = Identity.ensure(conn)
    render(conn, "tutorial.html.eex", Tutorial.assigns(handle))
  end)

  # --- SSE transport (reused verbatim from Chirp.SSEWeb) ---

  # Ingest only — the SSE stream is what actually paints the chirp.
  #
  # Guardrails (Chirp.Guard) gate the write: a per-IP fixed-window rate limit and
  # a server-side length/blank check. On any rejection we still return "" — the
  # POST is hx-swap="none" anyway (the SSE stream paints), so there is nothing to
  # swap on error; we just silently drop the over-limit/invalid chirp.
  post("/chirps", fn conn ->
    ip = conn.remote_ip |> :inet.ntoa() |> to_string()

    with :ok <- Chirp.Guard.check_rate(ip),
         {:ok, text} <- Chirp.Guard.normalize(conn.params["chirp"]) do
      Timeline.post(conn.params["handle"] || "anon", text)
    end

    ""
  end)

  # NOTE: sse/2 relocates this fn into a generated module, so aliases don't
  # reach here — call the Chirp.* modules fully-qualified.
  sse("/stream", fn
    :join, _socket ->
      Chirp.PubSub.subscribe(:sse)
      # Replay backlog oldest-first so afterbegin leaves the newest on top.
      Chirp.Timeline.list() |> Enum.reverse() |> Enum.each(&send(self(), {:chirp, &1}))
      :noreply

    {:received, {:chirp, chirp}}, _socket ->
      {:reply, %{event: "chirp", data: Chirp.Render.li(chirp)}}

    {:close, _reason}, _socket ->
      :ok
  end)

  # --- WebSocket transport (reused verbatim from Chirp.WSWeb) ---

  # NOTE: ws/2 relocates this fn into a generated module, so aliases don't reach
  # here — call the Chirp.* modules fully-qualified.
  ws("/socket", fn
    :join, _socket ->
      Chirp.PubSub.subscribe(:ws)
      # Replay backlog: send finished HTML straight to the client (bypasses us).
      Chirp.Timeline.list() |> Enum.reverse() |> Enum.each(&send(self(), Chirp.Render.oob(&1)))
      :noreply

    {:received, raw}, _socket ->
      # htmx's ws-send delivers the form fields as JSON (plus a HEADERS key).
      msg = Jason.decode!(raw)
      handle = msg["handle"] || "anon"

      # Same guardrails as the POST path. The Francis socket doesn't expose the
      # peer IP (its struct is %{id, transport, path, params}), so per the Guard
      # module's documented fallback we key the rate limit per handle here. On any
      # rejection (over-limit or blank/too-long) we drop the message silently.
      with :ok <- Chirp.Guard.check_rate(handle),
           {:ok, text} <- Chirp.Guard.normalize(msg["chirp"]) do
        Chirp.Timeline.post(handle, text)
      end

      :noreply

    {:close, _reason}, _socket ->
      :ok
  end)
end
defmodule Chirp.Chirp do
  @moduledoc "A single chirp. Plain data — no persistence, lives only in the Timeline GenServer."

  @enforce_keys [:id, :handle, :text, :at]
  defstruct [:id, :handle, :text, :at]

  @type t :: %__MODULE__{
          id: String.t(),
          handle: String.t(),
          text: String.t(),
          at: integer()
        }

  @spec new(String.t(), String.t()) :: t()
  def new(handle, text) do
    %__MODULE__{
      id: 8 |> :crypto.strong_rand_bytes() |> Base.encode16(case: :lower),
      handle: handle,
      text: text,
      at: System.system_time(:second)
    }
  end
end
defmodule Chirp.Timeline do
  @moduledoc """
  The whole "database": the most recent chirps, kept hot in an **ETS** table and
  periodically **dumped** to a single file so they survive a restart. No Ecto,
  no Postgres — `mix run` and the feed is live *and* durable.

  The ETS-as-working-set, file-as-backup pattern, split by concern:

    * **ETS** holds the live feed. Reads (`list/0`) hit the table directly from
      the *caller's* process — backlog replay for every new SSE/WS client no
      longer queues behind this GenServer's mailbox. The table is `:protected`,
      so only this process (the owner) writes; everyone else reads concurrently.
      Writes (`post/2`) still serialize through this GenServer's `handle_call`,
      so there is exactly one writer and reads never see a torn list.

    * **The dump file** is pure persistence, never on the hot path. We load it
      once on boot to warm ETS, then flush ETS → disk on a timer (and once more
      on graceful shutdown). `:erlang.term_to_binary/1` of the capped list is
      all it takes.

  ## Why not just ETS?

  ETS is *volatile*: the table dies with its owner process and with the VM, so
  the feed would evaporate on any restart. The file dump is what makes it durable.

  ## Why not DETS (or another disk store)?

  DETS is the obvious "ETS but on disk" answer, but it would put I/O on the read
  path and buy us problems we don't have here:

    * **Disk latency on the hot path.** Every `list/0` would touch disk. Our
      reads happen on *every* new client connect (backlog replay); we want them
      to stay in-memory fast.
    * **Weaker concurrency.** DETS has no `read_concurrency`-style parallel reads
      the way ETS does.
    * **2 GB file cap.** DETS tables are limited to ~2 GB.
    * **Dirty-file repair.** A DETS file not closed cleanly must be auto-repaired
      on open — a slow, scary boot. Our dump is a single `term_to_binary` blob:
      if it is corrupt we just start empty, no repair dance.

  Keeping ETS for reads and a plain dump file for persistence gives us in-memory
  speed *and* survive-a-restart durability, with no DETS ceremony.

  ## The durability flow

    1. **Load on boot** — `init/1` reads the dump file (if any) and warms ETS.
    2. **Flush on a timer** — every `@flush_interval` we write ETS → disk.
    3. **Flush on terminate** — `terminate/2` does one final write on clean exit.

  The trade-off is the **durability window**: a crash loses whatever was written
  since the last flush (up to `@flush_interval`). `terminate/2` closes the gap on
  a clean shutdown, but it is best-effort — a hard kill skips it, which is why
  the periodic flush, not `terminate/2`, is the real safety net.
  """
  use GenServer

  alias Chirp.{Chirp, PubSub}

  @table __MODULE__
  @key :chirps
  @max_chirps 100
  @flush_interval :timer.seconds(10)

  # The dump file location is configurable so a deploy can point it at a mounted
  # volume (e.g. Fly) via CHIRP_DUMP_PATH in runtime.exs. Defaults to priv.
  defp dump_path, do: Application.get_env(:chirp, :dump_path, "priv/chirps.dump")

  def start_link(_opts), do: GenServer.start_link(__MODULE__, [], name: __MODULE__)

  @doc "Newest-first list of recent chirps. Reads ETS directly — no GenServer call."
  @spec list() :: [Chirp.t()]
  def list do
    case :ets.lookup(@table, @key) do
      [{@key, chirps}] -> chirps
      [] -> []
    end
  end

  @doc """
  Record a chirp and broadcast it to all connected clients. Returns the chirp,
  or `:ignore` for blank text.
  """
  @spec post(String.t(), String.t()) :: Chirp.t() | :ignore
  def post(handle, text) do
    case String.trim(text || "") do
      "" -> :ignore
      text -> GenServer.call(__MODULE__, {:post, handle, text})
    end
  end

  @doc """
  Empty the feed. Goes through the GenServer so the single-writer invariant on
  the `:protected` ETS table is preserved. Used by `Chirp.Guard.Sweeper` for the
  scheduled auto-clear.
  """
  @spec clear() :: :ok
  def clear, do: GenServer.call(__MODULE__, :clear)

  @impl true
  def init(_) do
    # Trap exits so the supervisor's shutdown reaches terminate/2 for a final flush.
    # Without this, a :shutdown would kill us before we can write the dump.
    Process.flag(:trap_exit, true)
    # :protected — only the owner (us) writes; any process may read concurrently.
    # :named_table — readers find the table by name without holding a reference.
    :ets.new(@table, [:named_table, :set, :protected, read_concurrency: true])
    # Warm ETS from the on-disk dump so a restart doesn't start with an empty feed.
    :ets.insert(@table, {@key, load_dump()})
    schedule_flush()
    {:ok, %{}}
  end

  @impl true
  def handle_call({:post, handle, text}, _from, state) do
    # Writes serialize here: this is the single ETS writer, so prepend-and-cap
    # is race-free even though reads happen concurrently in other processes.
    chirp = Chirp.new(handle, text)
    :ets.insert(@table, {@key, Enum.take([chirp | list()], @max_chirps)})
    PubSub.broadcast(chirp)
    {:reply, chirp, state}
  end

  @impl true
  def handle_call(:clear, _from, state) do
    # Single-writer reset: keep the table shape ({@key, list}) so list/0 still
    # returns [] rather than crashing on a missing row.
    :ets.insert(@table, {@key, []})
    {:reply, :ok, state}
  end

  @impl true
  def handle_info(:flush, state) do
    # The real safety net: a periodic flush bounds data loss to one interval,
    # regardless of how the VM goes down.
    dump(list())
    schedule_flush()
    {:noreply, state}
  end

  @impl true
  # Best-effort final flush on a *clean* shutdown. A hard kill (SIGKILL, power
  # loss) skips this entirely — which is why the timer above is what we rely on.
  def terminate(_reason, _state), do: dump(list())

  # --- Persistence: ETS is the working set, this file is the backup ---

  defp schedule_flush, do: Process.send_after(self(), :flush, @flush_interval)

  defp dump(chirps), do: File.write(dump_path(), :erlang.term_to_binary(chirps))

  # The dump is a file we wrote ourselves, so plain binary_to_term is fine here.
  defp load_dump do
    case File.read(dump_path()) do
      {:ok, bin} -> :erlang.binary_to_term(bin)
      {:error, _} -> []
    end
  end
end
defmodule Chirp.PubSub do
  @moduledoc """
  Pub/sub fan-out built on `Registry` — Elixir's standard library, no Phoenix,
  no Redis. The *broadcaster*: it doesn't store chirps (that's `Chirp.Timeline`),
  it pushes them out. Every connected client (an SSE stream or a WebSocket) is
  one BEAM process that `subscribe/1`s here; `broadcast/1` fans a new chirp to
  all of them.

  The two transports differ in HOW the push reaches the client, and that
  difference is baked into Francis itself:

    * WebSocket: `send(socket.transport, msg)` BYPASSES the handler and is
      forwarded straight to the browser. So we send WS subscribers the final
      HTML (out-of-band wrapped) and htmx's ws extension swaps it in.

    * SSE: `send(socket.transport, msg)` ROUTES THROUGH the handler's
      `{:received, msg}` clause. So we send SSE subscribers the plain `%Chirp{}`
      and let their handler turn it into an `event: chirp` frame.
  """

  alias Chirp.Render

  @registry __MODULE__

  @doc "Called by a transport process (in its `:join` clause) to subscribe."
  @spec subscribe(:ws | :sse) :: {:ok, pid()} | {:error, term()}
  def subscribe(kind) when kind in [:ws, :sse], do: Registry.register(@registry, kind, nil)

  @doc "Fan a new chirp out to every connected client of either transport."
  @spec broadcast(Chirp.Chirp.t()) :: :ok
  def broadcast(chirp) do
    # WS: deliver finished HTML directly to the socket (bypasses the handler).
    Registry.dispatch(@registry, :ws, fn subscribers ->
      html = Render.oob(chirp)
      for {pid, _} <- subscribers, do: send(pid, html)
    end)

    # SSE: deliver the struct; the handler renders + frames it as an event.
    Registry.dispatch(@registry, :sse, fn subscribers ->
      for {pid, _} <- subscribers, do: send(pid, {:chirp, chirp})
    end)

    :ok
  end
end
defmodule Chirp.Render do
  @moduledoc """
  One chirp's HTML, via `francis_htmx`'s `~E` sigil. Like `~H`, `~E`
  auto-escapes interpolations, so `@handle`/`@text` are safe without manual
  escaping — do NOT also call `Francis.HTML.escape/1` or you double-escape.

  Two variants of the same markup:

    * `li/1`  — bare `<li>`, used as the SSE event payload. The SSE container
      has `sse-swap="chirp" hx-swap="afterbegin"`, so it's prepended for us.

    * `oob/1` — the same `<li>` wrapped in a throwaway `<div>` carrier that
      holds `hx-swap-oob="afterbegin:#feed"`, so htmx's ws extension knows
      where to drop a message that arrives unsolicited over the socket. The
      `swapStyle:selector` form of an OOB swap prepends the carrier's
      *children* into the target and discards the carrier itself — so the
      attribute must live on a wrapper, NOT on the `<li>`. Putting it on the
      `<li>` directly drops the `<li>` and prepends only the bare spans, which
      lose the `.chirp` row styling. The attribute is written literally (not
      interpolated) so `~E` won't escape it.

  Output is collapsed to a single line: SSE frames data line-by-line, so a
  newline would split the frame.
  """
  import FrancisHtmx

  alias Chirp.Chirp

  @spec li(Chirp.t()) :: binary()
  def li(chirp) do
    assigns = %{handle: chirp.handle, text: chirp.text}

    ~E"""
    <li class="chirp"><span class="handle">@<%= @handle %></span> <span class="text"><%= @text %></span></li>
    """
    |> flatten()
  end

  @spec oob(Chirp.t()) :: binary()
  def oob(chirp) do
    assigns = %{handle: chirp.handle, text: chirp.text}

    ~E"""
    <div hx-swap-oob="afterbegin:#feed"><li class="chirp"><span class="handle">@<%= @handle %></span> <span class="text"><%= @text %></span></li></div>
    """
    |> flatten()
  end

  defp flatten(rendered), do: rendered |> IO.iodata_to_binary() |> String.trim()
end
defmodule Chirp.Identity do
  @moduledoc """
  Throwaway identity so the demo needs no login. A random handle is minted once
  and stored in a cookie, then rendered into a hidden form field. Both demos
  send that field with each chirp (the SSE demo over HTTP, the WS demo over the
  socket), so the same mechanism works for both.
  """

  @adjectives ~w(quick lazy brave clever calm sly bold keen wary jolly)
  @animals ~w(otter koala raven lynx finch heron tapir gecko bison moth)

  @doc "Return `{conn, handle}`, setting a `handle` cookie the first time."
  @spec ensure(Plug.Conn.t()) :: {Plug.Conn.t(), String.t()}
  def ensure(conn) do
    conn = Plug.Conn.fetch_cookies(conn)

    case conn.cookies["handle"] do
      nil ->
        handle = random()
        {Plug.Conn.put_resp_cookie(conn, "handle", handle, max_age: 86_400), handle}

      handle ->
        {conn, handle}
    end
  end

  defp random do
    "#{Enum.random(@adjectives)}#{Enum.random(@animals)}#{:rand.uniform(99)}"
  end
end
defmodule Chirp.Application do
  @moduledoc false
  use Application
  require Logger

  @impl true
  def start(_type, _args) do
    # ONE listener, ONE port: the tutorial app mounts SSE, WS, and the page
    # under a single origin (so Fly has a single thing to expose).
    port =
      Application.get_env(:chirp, :port) || String.to_integer(System.get_env("PORT") || "4080")

    children = [
      # Pub/sub registry: connected clients subscribe, Timeline dispatches.
      {Registry, keys: :duplicate, name: Chirp.PubSub},
      # The in-memory feed (ETS working set + file dump).
      Chirp.Timeline,
      # Guardrails housekeeping: periodically clears the public feed and sweeps
      # expired rate-limit windows. The per-IP rate limiter itself needs no child
      # (its ETS table is created lazily on first use).
      {Chirp.Guard.Sweeper, clear_fun: &Chirp.Timeline.clear/0},
      # The consolidated tutorial app: one Francis plug carrying every route.
      {Bandit, plug: Chirp.SiteWeb, scheme: :http, port: port}
    ]

    # Honest in server logs: the bind is on all interfaces, so don't claim
    # "localhost" (which only resolves when you're running it locally). The dev
    # hint is parenthetical; in prod the real URL is the host's address.
    Logger.info("Chirp tutorial listening on port #{port} (http://localhost:#{port} in dev)")

    Supervisor.start_link(children, strategy: :one_for_one, name: Chirp.Supervisor)
  end
end

That's a live, multi-user, persistent message board with two realtime transports — and not a single piece of external infrastructure. That's the BEAM's pitch, in one small app.