Why I Looked Beyond Ruby
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.
The Mindset Shift: From Objects to Functions
Life Before: Classes and Objects
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.
A New Paradigm in Elixir
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 hidden state: Every function receives data as input and returns a new copy of that data, so you always know where transformations happen.
No more deep class hierarchies: In Elixir, code sharing happens via modules and function imports rather than extending base classes.
Example: Refactoring a Class into a Module
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.
Concurrency: Learning to Trust Processes
Ruby’s approach
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 and the BEAM
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.
Example: Background Jobs
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:
- Batches and Callbacks: Enables grouping jobs into sets that can be tracked collectively programmatically or within the Sidekiq Web interface, with the ability to execute callbacks once all jobs in a batch are complete.
- Enhanced Reliability: Utilizes Redis’s RPOPLPUSH command to ensure that jobs are not lost if a process crashes or is terminated unexpectedly. Additionally, the Sidekiq Pro client can withstand transient Redis outages or timeouts by enqueueing jobs locally upon error and attempting delivery once connectivity is restored.
- Queue Pausing and Scheduling: Allows for pausing queues (e.g., processing a queue only during business hours) and expiring unprocessed jobs after a specified deadline, providing greater control over job processing times.
Oban Pro:
- Workflows: Enables composing jobs with arbitrary dependencies, allowing for sequential, fan-out, and fan-in execution patterns to model complex job relationships.
- Global Concurrency and Rate Limiting: Provides the ability to limit the number of concurrent jobs running across all nodes (global concurrency) and to restrict the number of jobs executed within a specific time window (rate limiting).
- Dynamic Cron: Offers cron configuration scheduling before boot or during runtime, globally, with scheduling guarantees and per-entry timezone overrides. It’s an ideal solution for applications that can’t miss a cron job or must dynamically start and manage scheduled jobs at runtime.
Their open-source cores, however, already cover the most common background job needs and are well-suited for many production applications.
Debugging and Fault Tolerance: A New Perspective
Catching Exceptions in Ruby
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.
Supervision Trees in Elixir
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.
LiveView: A Game-Changer for Web Development
Why I Loved Rails
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.
Phoenix + LiveView
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.
My Takeaways
Embracing Immutability
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.
Let It Crash
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.
Less JavaScript, More Productivity
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.
Still Love Ruby
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.
Final Advice for Rubyists Curious About Elixir
- Start Small: Experiment with a tiny service or background job. Don’t rewrite your entire monolith on day one.
- Get Comfortable with Functional Concepts: Embrace immutability and pattern matching. The mental shift is real, but it pays off.
- Check Out Phoenix and LiveView: If you’re doing web dev, see how your typical Rails flow translates in Phoenix. And definitely try LiveView.
- Utilise Existing Ruby Skills: Your understanding of test-driven development, domain modeling, and code readability all carry over—you’ll just write them differently.
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.
The post My Journey from Ruby to Elixir: Lessons from a Developer appeared first on Erlang Solutions.