OneRuby.devAN ENGINEERING NOTEBOOK

ruby · 5 min read

Ruby vs Elixir: compare the state transition before the syntax

Run the same lost-update experiment in Ruby and Elixir. See why a mutex or single-owner process needs to protect the whole operation.

Two clients increment a counter. The final value should be two. Put the counter behind an Elixir process and it is tempting to assume that the concurrency problem has disappeared.

It has not, if each client first asks for the value and later sends a replacement. Both can read zero, both can calculate one, and the owner can faithfully process two requests to set the value to one.

The same mistake is possible with Ruby threads, even when an object owns its state. Before choosing Ruby or Elixir for a project, it helps to run one shared experiment: what is the smallest state transition that must happen without another client intervening?

Force the failure instead of waiting for it

The Ruby lab deliberately coordinates two clients. Each reads, reports that it is ready, then waits for permission to write:

Ruby
def split_updates(read, write)
ready, go = Queue.new, Queue.new
threads = 2.times.map do
Thread.new do
old = read.call
ready << true
go.pop
write.call(old + 1)
end
end
2.times { ready.pop }
2.times { go << true }
threads.each(&:value)
ensure
threads&.each { |thread| thread.kill if thread.alive? }
end

The main thread waits for both readiness messages before releasing either client. This makes the lost update deterministic. There is no sleep duration chosen in the hope that a particular schedule will occur.

The first test uses a shared Ruby Hash. The second arrangement routes reads and writes through a dedicated owner thread with a Queue. Both split operations finish at one. The owner processes one message at a time, but it cannot combine two separate client messages into a transaction.

The barriers are testing tools, not application architecture. They establish a specific interleaving that the contract must survive. Ruby's Queue supplies the communication mechanism, and joining the client threads ensures the assertion runs after their work.

Move the whole transition to one boundary

The Ruby repair can use a Mutex around the complete read-modify-write operation. The lab also implements an increment message in the owner thread, so the owner performs the addition itself. Twenty concurrent requests then produce twenty.

In Elixir, the equivalent repair sends the calculation to Agent.update:

Elixir
1..20
|> Enum.map(fn _ -> Task.async(fn -> Agent.update(counter, &(&1 + 1)) end) end)
|> Enum.each(&Task.await(&1, 5_000))

The callback receives the state at the point when the agent executes the request. It does not capture a value fetched earlier by a client. The Agent documentation explicitly distinguishes work inside the server from work performed after a client retrieves state.

The Elixir counterexample uses the same two-phase barrier: both tasks fetch zero, then both replace the agent's state with their previously calculated one. Its passing test asserts the broken result. A second test asserts the repaired twenty-request result.

These tests make a limited but useful comparison. Message passing changes where mutable state is owned. It does not automatically identify which collection of messages constitutes one business operation.

What the runtimes change

Ruby gives this small example direct shared objects, threads, a mutex and queues. That can be enough when the application already has a clear ownership rule. The little owner class is hand-written plumbing: request envelopes, reply queues and explicit shutdown.

Elixir's process model makes isolated state and message exchange ordinary building blocks. Agent supplies the server wrapper here; GenServer offers a broader request-handling abstraction. OTP supervision provides a separate framework for managing process lifecycles. The lab does not test a supervisor or recover a crashed service.

A supervised restart also should not be confused with durable state. Both examples keep only an integer in memory. Losing the owning process can lose that value. Persisting an increment, deduplicating a retried request and deciding whether a caller's timeout means "not applied" are additional problems in either language.

The Ruby example likewise lacks time-bounded application requests, backpressure and a recovery protocol. Its tests have deadlines to detect a hang, but that is not a production request API.

Choose against your actual project

The comparison becomes more useful when written as requirements:

QuestionWhat to investigate
Most work is database-backed CRUDExisting team fluency, framework conventions and deployment operations
Many independently owned, long-lived sessionsState ownership, process lifecycle, observability and recovery behavior
One shared state transition is the bottleneckWhether it can be partitioned; one owner serializes its requests
A request calls an external serviceTimeouts, retry identity and reconciliation in either stack
CPU work dominatesMeasure the actual runtime, libraries and execution model with representative input

This table does not declare a framework winner. A team with an existing Rails application may get more value from correcting its transaction boundaries than from migrating languages. A system built around many independently managed sessions may find OTP's abstractions worth learning. Those are architectural judgments to validate with a representative slice of the project.

The counter experiment offers no requests-per-second ranking, memory-per-connection estimate or forecast of development time. A loop around an integer would not answer those questions.

Run both versions

Download the Ruby example and Elixir example. The recorded runtimes are Ruby 3.3.2 with Minitest 6.0.6, and Elixir 1.18.3 on Erlang/OTP 27. Install the exact Minitest version if your Ruby environment lacks it.

Terminal
ruby example.rb --seed 42
Terminal
elixir example.exs

The Ruby run passed 4 tests and 4 assertions. Elixir's ExUnit run passed 2 tests. Both deliberately reproduce a lost update and demonstrate a complete atomic increment. No network, database or framework server is started.

For a first project spike, replace the integer with one real state transition from your application. Keep the forced interleaving. Then ask what happens if a request is repeated or the owner disappears. The answers will reveal more about the fit of either stack than a list of language slogans.

Found a mistake or tried a different approach?

Send Alex a note ↗