Contents

Why I built Speechwave in Elixir (as a Rails developer)

Why I built Speechwave in Elixir (as a Rails developer)

I’ve been developing with Ruby on Rails for a long time. If you had asked me to bet on what I’d build my next web app with, I would have bet on Rails.

Then I started building Speechwave, a live emoji reaction app for conference talks, and I wrote it in Elixir instead

This isn’t a “Rails is dead” post. It’s still a great choice for lots of things. This is about what this particular app needed, why Elixir turned out to a great choice, and why the hosting bill is about as small as a bill can get. If you’re an Elixir developer, some of this may be familiar.

What Speechwave does

Attendees scan a QR code at a talk and are sent to a page of emoji buttons. They tap one. A Chrome extension on the speaker’s laptop receives that reaction and floats the emoji over the speaker’s Google Slides in real time. After the talk, the speaker can see which slides got which reactions.

Look at that from a server’s point of view. It’s a lot of small, short-lived, simultaneous things:

  • Dozens (or hundreds) of phones holding open WebSocket connections
  • Each tap needs to be rate limited, optionally saved, and fanned out to everyone who cares
  • The speaker’s extension holds one more long-lived connection, and it isn’t even a web page
  • The whole thing is quiet for days, then very busy for 45 minutes

That last point will matter quite a bit when we get to the hosting section.

Why it works: the three things I would have had to do in Rails

I don’t want to oversell this. Rails can do real-time. Action Cable exists, and Rails keeps improving. But in Rails I would have been assembling pieces. In Phoenix, the pieces come in the box and they were designed together.

1. A message bus that doesn’t care who’s listening

Phoenix PubSub lets any process broadcast to a topic and any other process subscribe to it. The attendee’s LiveView doesn’t know the Chrome extension exists. After the rate limit check (and saving the reaction, if a session is recording), it just broadcasts. Here’s the handler from talk_live.ex, with the persistence code trimmed out:

def handle_event("react", %{"emoji" => emoji}, socket) do
  if RateLimiter.allow?(socket.assigns.session_id) do
    # ...save the reaction if a session is recording...

    SpeechwaveWeb.Endpoint.broadcast!(
      "reactions:#{socket.assigns.talk.slug}",
      "new_reaction",
      %{emoji: emoji}
    )
  end

  {:noreply, socket}
end

The extension’s channel is subscribed to the same topic, so it gets the message. If it add a third kind of listener tomorrow, say a live dashboard, nothing in that function changes.

2. LiveView for the attendee page

The attendee page is a LiveView, so we got WebSocket-backed interactivity without building a single-page app and without maintaining a separate JavaScript frontend. The UI is a function of server state, and Phoenix handles keeping the browser in sync. For an app where nearly every interesting event is “something happened on the server, tell the clients,” that model is a very good match.

3. Cheap, isolated processes for everything else

The rate limiter is a GenServer that owns an ETS table (an in-memory key/value store built into the runtime). Reads don’t queue behind the GenServer, so a room full of people tapping at once doesn’t turn into a traffic jam. It’s a few dozen lines and there is no Redis anywhere.

Presence, which tracks how many extensions are connected to a talk so we can enforce plan limits, is also built in. So is the supervision tree that restarts any piece that crashes without taking down the rest. Here’s the top level of the tree from application.ex, trimmed a bit for space:

children =
  [
    Speechwave.Repo,
    {Phoenix.PubSub, name: Speechwave.PubSub},
    Speechwave.RateLimiter,
    Speechwave.AuthThrottle,
    # ...a few more throttles and reapers...
    SpeechwaveWeb.Endpoint,
    SpeechwaveWeb.Presence
  ] ++ backup_children()

opts = [strategy: :one_for_one, name: Speechwave.Supervisor]
Supervisor.start_link(children, opts)

I wrote down what happens when each child crashes (the rate limiter loses its cooldown state, Presence resets its count, and so on) and for this app every answer is “acceptable, by design.” Thinking about failure that explicitly is something the platform nudges you toward. In a Rails apps, I mostly find out what a crash does when it happens.

The hosting bill

This is the part I was the most excited about, and why I’m writing this post in the first place.

Speechwave runs on Fly.io, and here are the essential bits configuration:

[[vm]]
  memory = '512mb'
  cpu_kind = 'shared'
  cpus = 1

[http_service]
  auto_stop_machines = 'suspend'
  auto_start_machines = true
  min_machines_running = 0

That’s one shared CPU and 512 MB of memory. When nobody is using the app, the machine suspends. When someone scans a QR code, it wakes up.

Three things make that work, and Elixir is part of each of them.

The runtime is frugal. Because the BEAM runs lightweight processes instead of OS threads, one small machine can hold a lot of open connections. I’ve configured the app to allow up to 1,000 concurrent connections on that single 512 MB box. For context, my realistic target is a few conference rooms, not a stadium.

One process serves everything. The reaction fan-out, the LiveView pages, the Channel for the extension, the rate limiter, the presence tracking, and the scheduled backups all run inside a single Elixir application. I don’t need a separate Redis for pub/sub, a separate worker process for background jobs, or a separate WebSocket server. Fewer moving parts means fewer things to pay for and fewer things to keep alive.

SQLite is enough. The first version of this app used Fly’s managed Postgres. I’ve since moved to SQLite on a Fly volume, with Ecto’s SQLite adapter and a pool size of 2. Reactions are small writes, the data volume is tiny, and a few talk-shaped workloads doesn’t need a database server.

That does mean I own backups. A GenServer takes a snapshot every hour using SQLite’s VACUUM INTO (which gives a consistent copy of a live database, unlike copying the file) and uploads it to an S3-compatible object storage. It’s about a hundred lines of Elixir, it’s supervised like everything else, and a failed backup logs an error instead of crashing anything. If you want the details, the codebase explainer walks through it.

Put those together and the cost is tiny. Fly waives invoices under $5, and only last month did my bill creep above that to $5.49, just a little over the line. Usage of the S3 object store at Tigris has stayed within its free allowances.

I want to be careful about what I’m claiming. That number reflects low traffic on a young app. I haven’t load tested a 5,000-person keynote, and I’m not claiming this configuration handles one. What I can say is that the floor is very low, and the architecture doesn’t force me to pay for a fleet of services before I’ve earned a paying user.

Could I have done this in Rails?

Probably, yes. Rails 8 made SQLite a first-class citizen, and Solid Cable removes the Redis requirement for Action Cable. I’m not going to pretend that Rails can’t get to a similar place. A fair comparison, though, is about how much I had to assemble and tune to get there, and about how comfortable the result is at idle and at peak.

For this specific shape of app, many long-lived connections, constant broadcast, tiny writes, spiky traffic, Phoenix gave me the right design without much negotiation. In Rails I would have been making deliberate choices to approximate it.

I’ll also say what I did not get for free. Rails has an enormous ecosystem of gems that solve the boring parts of an app. In Elixir I wrote more of the glue myself. Authentication is a good example: magic links and OAuth took more work (Phoenix’s generator got me started, and a library called Assent handles the OAuth side). If this had been a plain CRUD app with admin screens, Rails may have been faster to build, and I may have shipped it sooner.

What learning Elixir was like for a Ruby developer

Easier than I expected, honestly. The syntax is friendly to Rubyists, the tooling (mix, iex, ExUnit) feels familiar, and the community writes really good documentation. The adjustments that took some effort were more conceptual:

  • Data is immutable, so you transform values instead of mutating objects.
  • Pattern matching shows up everywhere, and it replaces a surprising amount of if and case logic.
  • You think in processes, which is a new mental model, but a pleasant one once it clicks.

If you’ve used Ruby 3’s case/in, pattern matching won’t be a brand new idea. You can destructure hashes and arrays, bind variables, and pin values with ^:

case response
in {status: 200, body:}
  render(body)
in {status: 404}
  not_found
end

The difference is how deep it goes. In Ruby, pattern matching is a feature you opt into with case/in, and most Ruby code still uses if, case/when, and guard clauses. In Elixir it’s foundational. Even = is a match operator, not just assignment. Most importantly, you can match right in a function’s arguments, so each clause of a function handles one shape of input. Speechwave’s Channel has two join functions, for example (from reaction_channel.ex, with the first one’s body trimmed):

def join("reactions:" <> slug, %{"api_key" => api_key}, socket) do
  # ...check the talk, the user, and the plan limits...
end

def join("reactions:" <> _slug, _params, _socket) do
  {:error, %{reason: "unauthorized"}}
end

The first clause only matches when the topic starts with "reactions:" and the params include an api_key. Anything else falls through to the second clause, which rejects it. No if required.

Ruby methods have one body, so you branch inside it. Elixir functions can have many clauses, and the branching happens before your code even runs. That’s the part that changed how code was structured, and it’s why pattern matching feels more like a way of working than a convenient syntax.

Here’s an example. The Channel join for the Chrome extension is a chain of four checks, and each failure has its own reason. This is the body of that first join clause, minus a couple of setup lines inside the do block:

with {:talk, %Talks.Talk{} = talk} <- {:talk, Talks.get_talk_by_slug(slug)},
     {:user, %Accounts.User{} = user} <- {:user, Accounts.get_user_by_api_key(api_key)},
     {:owner, true} <- {:owner, talk.user_id == user.id},
     {:capacity, :ok} <-
       {:capacity,
        Plans.check(
          :max_participants,
          user.plan,
          Presence.list("reactions:#{slug}") |> map_size()
        )} do
  # ...subscribe to disconnect events, schedule presence tracking...
  {:ok, join_payload(user), assign(socket, talk: talk, user: user)}
else
  {:talk, nil} -> {:error, %{reason: "not_found"}}
  {:user, nil} -> {:error, %{reason: "unauthorized"}}
  {:owner, false} -> {:error, %{reason: "unauthorized"}}
  {:capacity, {:error, :limit_reached}} -> {:error, %{reason: "capacity_reached"}}
end

I might have written that as a pile of guard clauses in Ruby. This version reads top to bottom as a list of requirements, and the else block is a table of what goes wrong.

As an aid to learning I had AI help me write a short course for Ruby developers, drawing the examples from the Speechwave codebase. If you’re coming from the Rails side, that’s an interesting place to start.

What I’d tell another Rails developer

If your app is mostly forms, tables, and background jobs, stay with Rails. You’ll move faster and you’ll have more gems to lean on.

If your app is mostly “lots of people connected at once, and something happening to all of them in real time,” look hard at Phoenix. The runtime and the framework were built for exactly that, and you may find, as I did, that the infrastructure shrinks down to a single small machine.

And if you’re already an Elixir developer: I’d love your feedback on the codebase. It’s open, it’s about 6,800 lines of application code, and I’m sure there are idioms I’m still getting wrong.