My Journey from Ruby to Elixir: Lessons from a Developer
- Oleg Ivanov
- 27th Mar 2025
- 15 min of reading time
For years, Ruby was my go-to language for building everything from small prototypes to full-fledged production apps. I fell in love with its elegance and expressiveness and how Ruby on Rails could turn an idea into a working web app in record time. The community—with its focus on kindness and collaboration—only deepened my appreciation. In short, Ruby felt like home.
But as my projects grew in complexity, I started running into bottlenecks. I had apps requiring real-time features, massive concurrency, and high availability. Scaling them with Ruby often meant juggling multiple processes, external services, or creative threading approaches—all of which worked but never felt truly seamless. That’s when I stumbled upon Elixir.
At first glance, Elixir’s syntax reminded me of Ruby. It looked approachable and developer-friendly. But beneath the surface lies a fundamentally different philosophy, heavily influenced by Erlang’s functional model and the concurrency power of the BEAM. Moving from Ruby’s object-oriented approach to Elixir’s functional core was eye-opening. Here’s how I made that transition and why I think it’s worth considering if you’re a fellow Rubyist.
In Ruby, I approached problems by modeling them as classes, bundling data and behavior together. It was second nature to create an @name
instance variable in an initializer, mutate it, and rely on inheritance or modules to share behavior. This style allowed me to write expressive code, but it also hid state changes behind class boundaries.
Elixir flips that script. Data is immutable, and functions are the stars of the show. Instead of objects, I have modules that hold pure functions. Instead of inheritance, I rely on composition and pattern matching. This required me to unlearn some habits.
No more deep class hierarchies: In Elixir, code sharing happens via modules and function imports rather than extending base classes.
Ruby
class Greeter
def initialize(name)
@name = name
end
def greet
"Hello, #{@name}!"
end
end
greeter = Greeter.new("Ruby")
puts greeter.greet # => "Hello, Ruby!"
Elixir
defmodule Greeter do
def greet(name), do: "Hello, #{name}!"
end
IO.puts Greeter.greet("Elixir") # => "Hello, Elixir!"
At first, I missed the idea of storing state inside an object, but soon realized how clean and predictable code can be when data and functions are separated. Immutability drastically cut down on side effects, which in turn cut down on surprises.
Ruby concurrency typically means spinning up multiple processes or using multi-threading for IO-bound tasks. If you need to queue background jobs, gems like Sidekiq step in. Sidekiq runs in its own OS processes, separate from the main web server, and these processes can run on multiple cores for true parallelism. This approach is straightforward but often demands more memory and additional infrastructure for scaling.
On the plus side, Ruby can handle many simultaneous web requests if they’re primarily IO-bound (such as database queries). Even with the Global Interpreter Lock (GIL) limiting the parallel execution of pure Ruby code, IO tasks can still interleave, allowing a single OS process to serve multiple requests concurrently.
Elixir, on the other hand, was built for concurrency from the ground up, thanks to the BEAM virtual machine. It uses lightweight processes (not OS processes or threads) that are cheap to create and easy to isolate. These processes don’t share memory but communicate via message passing—meaning a crash in one process won’t cascade.
Ruby (Sidekiq)
class UserSyncJob
include Sidekiq::Worker
# This job fetches user data from an external API
# and updates the local database.
def perform(user_id)
begin
# 1. Fetch data from external service
external_data = ExternalApi.get_user_data(user_id)
# 2. Update local DB (pseudo-code)
user = User.find(user_id)
user.update(
name: external_data[:name],
email: external_data[:email]
)
puts "Successfully synced user #{user_id}"
rescue => e
# If something goes wrong, Sidekiq can retry
# automatically, or we can log the error.
puts "Error syncing user #{user_id}: #{e.message}"
end
end
end
# Trigger the job asynchronously:
UserSyncJob.perform_async(42)
Elixir (Oban)
Although GenServer is often used to showcase Elixir’s concurrency model, a more accurate comparison to Sidekiq would be Oban – a background job processing library.
defmodule MyApp.Workers.UserSyncJob do
use Oban.Worker, queue: :default
@impl Oban.Worker
def perform(%{args: %{"user_id" => user_id}}) do
with {:ok, external_data} <- ExternalApi.get_user_data(user_id),
%User{} = user <- MyApp.Repo.get(User, user_id) do
user
|> User.changeset(%{
name: external_data.name,
email: external_data.email
})
|> MyApp.Repo.update!()
IO.puts("Successfully synced user #{user_id}")
else
error -> IO.puts("Error syncing user #{user_id}: #{inspect(error)}")
end
:ok
end
end
# Enqueue the job asynchronously:
MyApp.Workers.UserSyncJob.new(%{"user_id" => 42})
|> Oban.insert()
With Oban, jobs are persistent, retried automatically on failure, and can survive restarts – just like Sidekiq. It leverages Elixir’s process model but gives you the robustness of a mature job queueing system. Since it stores jobs in PostgreSQL, you get full visibility into job states and histories without adding extra infrastructure. Both libraries offer paid tiers – Sidekiq Pro , Oban Pro.
Here are some notable features offered in the Pro versions of Sidekiq and Oban:
Sidekiq Pro:
Oban Pro:
Their open-source cores, however, already cover the most common background job needs and are well-suited for many production applications.
Error handling in Ruby typically involves begin/rescue blocks. If a critical background job crashes, I might rely on Sidekiq’s retry logic or external monitoring. It worked, but I always worried about a missed exception bringing down crucial parts of the app.
Elixir uses a concept called a supervision tree, inherited from Erlang’s OTP. Supervisors watch over processes, restarting them automatically if they crash. At first, I found it odd to let a process crash on purpose instead of rescuing the error. But once I saw how quickly the supervisor restarted a failed process, I was hooked.
defmodule Worker do
use GenServer
def start_link(_) do
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
end
def init(_), do: {:ok, %{}}
def handle_call(:risky, _from, state) do
raise "Something went wrong"
{:reply, :ok, state}
end
end
defmodule SupervisorTree do
use Supervisor
def start_link(_) do
Supervisor.start_link(__MODULE__, :ok, name: __MODULE__)
end
def init(:ok) do
children = [
{Worker, []}
]
Supervisor.init(children, strategy: :one_for_one)
end
end
Now, if Worker
crashes, the supervisor restarts it automatically. No manual intervention, no separate monitoring service, and no global meltdown.
Rails made it trivial to spin up CRUD apps, handle migrations, and integrate with robust testing tools like RSpec. But building real-time interactions (like chat or real-time dashboards) could be tricky without relying heavily on JavaScript frameworks or ActionCable.
Elixir’s Phoenix framework parallels Rails in many ways: fast bootstrapping, a clear folder structure, and strong conventions. But Phoenix Channels and LiveView push it even further. With LiveView, I can build highly interactive, real-time features that update the DOM via websockets—all without a dedicated front-end framework.
Elixir (Phoenix LiveView)
defmodule ChatLive do
use Phoenix.LiveView
def mount(_params, _session, socket) do
{:ok, assign(socket, :messages, [])}
end
def handle_event("send", %{"message" => msg}, socket) do
{:noreply, update(socket, :messages, fn msgs -> msgs ++ [msg] end)}
end
def render(assigns) do
~H"""
<h1>Chat</h1>
<ul>
<%= for msg <- @messages do %>
<li><%= msg %></li>
<% end %>
</ul>
<form phx-submit="send">
<input type="text" name="message" placeholder="Type something"/>
<button type="submit">Send</button>
</form>
"""
end
end
This simple LiveView code handles real-time chat updates directly from the server, minimising the JavaScript I need to write. The reactive UI is all done through server-rendered updates.
At first, it was tough to break free from the habit of mutating data in place. But once I got comfortable returning new data structures, my code became far more predictable. I stopped chasing side effects and race conditions.
Ruby taught me to rescue and recover from every possible error. Elixir taught me to trust the supervisor process. This “let it crash” philosophy took some getting used to, but it simplifies error handling significantly.
LiveView drastically cut down my front-end overhead. I don’t need a full client framework for real-time updates. Seeing how quickly I could build a proof-of-concept live chat convinced me that Elixir was onto something big.
None of this means I dislike Ruby. I still think Rails is fantastic for many use cases, especially when you need to prototype something quickly or build a classic CRUD app. Ruby fosters a developer-friendly environment that many languages can only aspire to. I simply reached a point where concurrency and fault tolerance became a top priority—and that’s where Elixir really shines.
Ultimately, if you’re running into the same scaling or concurrency issues I did, Elixir might just be the upgrade you need. It brings a breath of fresh air to large-scale, real-time, and fault-tolerant applications while keeping developer happiness front and center. For me, it was worth the leap, and I haven’t looked back since. If you’re looking for a detailed comparison of Elixir and Ruby, our comprehensive Elixir vs. Ruby guide has you covered.
Catch up on the latest from Erlang Solutions. This blog round-up covers key tech trends, including big data, digital wallets, IoT security, and more.
Learn how Elixir can improve business performance by reducing costs, enhancing efficiency, and speeding up time to market.
Paweł Długosz explores how Elixir’s concurrency model and fault-tolerant design simplify scalable system development for developers