What I learned migrating a Rails platform to JRuby, and back again
We migrated a production Rails platform from MRI to JRuby in August 2018. It benchmarked about 25% faster on the query we cared about, and roughly halved the duration of our CPU-heavy jobs. We switched back to MRI six months later. This is why the gain didn't matter, and the rule I've applied ever since.
The problem we actually had
The product is a reporting platform for performance marketers. It pulls data from a lot of advertising and analytics APIs, joins ad data against analytics data, and serves interactive queries over the result. A customer can define their own metrics as formulas — a ratio of two base metrics, a conditional, a metric derived from other derived metrics. Those definitions form a dependency graph, and answering a query means evaluating it.
The graph got big. We hit a query where computing one node depended on around a hundred others, which between them depended on roughly two thousand more, and it timed out. Some of the cost was I/O, but the part that hurt was resolving node dependencies and evaluating expressions per row. When we flattened a layer of the dependency graph by hand, the query came back faster — which told us the shape of the graph, not the storage, was the bottleneck.
There was no obvious optimisation left except to evaluate independent nodes in parallel. And Ruby's reference implementation has a global interpreter lock: threads help when you're waiting on I/O, and do nothing for CPU-bound work but add context switches.
Why JRuby looked like the right answer
JRuby maps Ruby threads onto JVM threads, which map onto native threads. No GIL, real parallelism on a multi-core box. And — this was the seductive part — the code stays the same.
We had also noticed the same problem elsewhere. We were running a few hundred cron jobs pulling data from external APIs. Most were I/O-bound, but some fetched a date range and then did real work on the result. Rather than carve one CPU-bound component out into a separate service, we could change the platform underneath everything and get parallelism across the board.
That framing — change the runtime, keep the code — is the mistake. I didn't see it at the time because the first measurements were good.
What we measured
Before going to production we ran a long-running query on a beta environment about ten thousand times against both runtimes. JRuby came out roughly 25% faster. After we shipped it, the jobs doing heavy CPU work almost halved in duration.
Those numbers were real. They were also the wrong numbers, and I'll come back to that.
What broke
1. A JVM inside Kubernetes is a different animal
Our jobs ran as Kubernetes pods with resource requests. A request reserves a minimum; it does not cap you. Under MRI, jobs had quietly been using more than they asked for whenever the node had room, and nobody noticed because nothing failed.
A JVM takes its heap at startup and cannot grow past it. The moment those jobs ran on the JVM, the over-consumption stopped being invisible and started being OOM kills. The information was valuable — we genuinely had jobs using far more memory than they claimed. But we learned it by having production jobs die.
2. The GIL had been hiding real concurrency bugs
This was the expensive one. We had code that initialised an ActiveRecord object and then touched it from several threads. Something like:
@account = Account.first
tasks = []
10_000.times { tasks << -> { @account.name } }
10_000.times { tasks << -> { @account.update!(name: 'random') } }
Parallel.each(tasks.shuffle, in_threads: 6, &:call)
Under MRI this is fine, because no two threads run at the same time. Under JRuby it blows up
inside ActiveRecord's transaction-state bookkeeping, with a NoMethodError on
nil several frames deep in sync_with_transaction_state.
I proposed a mutex around the transaction state
(rails/rails#34658). It wasn't merged, and
the maintainer's answer was the actually useful output: Rails does not support sharing an
ActiveRecord::Base instance between threads, and it can't, because any state on that
object would need its own mutex. The bug was ours. It had been in the codebase for years,
completely invisible, protected by an implementation detail of the interpreter.
That is worth sitting with. A single-threaded runtime doesn't just fail to give you parallelism — it silently underwrites code that isn't thread-safe. Switching runtime doesn't add parallelism to your application; it removes a guarantee your application was unknowingly depending on.
3. Development got slow enough to change behaviour
Our test suite went from about ten minutes to about thirty. Booting a Rails console or a development server took long enough to be genuinely unpleasant.
It's tempting to file this under "developer comfort" and discount it. Don't. A test suite that takes thirty minutes is a test suite people run less often, and a console that takes a minute to boot is a debugging session that doesn't happen. The cost lands on your defect rate a quarter later, where you won't attribute it correctly.
4. Deploys became customer-visible
We deployed as rolling restarts. A freshly started JVM is slow until it has warmed up, and with enough of the fleet cycling at once the platform became, in my note at the time, "extremely slow, completely unusable to the customers" during a release. We moved to scheduled releases, which is a polite way of saying we could no longer deploy whenever we wanted.
What we found upstream
The migration was, in fairness, a productive bug-finding exercise. The JRuby maintainers were fast and generous, which made it worth reporting things properly.
- jruby/jruby#5350 — merged.
We use MurmurHash for a sharding key, and Zlib's CRC32 threw
ArrayIndexOutOfBoundsExceptionfor negative keys. Java's zlib takes the start value as along, so a sign-extended negative number never shifts down to zero and the loop walks off the end of a 32-slot array. Masking the value to 32 bits fixes it. - activerecord-jdbc-adapter#926
— the adapter's
postgresql_versionparser predated PostgreSQL 10's two-part version scheme and misread it. My patch wasn't taken, but the diagnosis was, and the issue is closed. - activerecord-jdbc-adapter#941
— merged. Locating
jruby.jaron the classpath viarbconfigfor Java 9 and later. - activerecord-jdbc-adapter#933
— still open. The JDBC driver can't distinguish PostgreSQL's
jsonboperators (?,?|) from bind-parameter placeholders. It didn't bite us, because we run PgBouncer and therefore don't use prepared statements. - concurrent-ruby#780
— merged. A
java_aliasofRunnable#submitthat broke under a newer JDK. - rake-compiler#147 and #148 — merged. Then I found that #147 broke cross-compilation, reported it, and #151 fixed it. Which is its own small lesson about landing a fix in a build tool.
- jruby#5309 and
jruby#5426 — a UTF-8 byte-sequence
bug in
StringIO, and an exception initialising aSetfrom an empty array with a block.
Going back
We returned to MRI in February 2019. It resolved the concurrency crashes without touching the application code — which I want to be honest about: fixing every unsafe cross-thread access would have been the correct repair, and we chose not to spend that time. It removed the rolling-restart problem. It gave us our test suite back.
Here is the part that actually matters. When we went looking at where time was really going, the proportion of requests that were CPU-bound was small. The application is dominated by I/O. We had built a whole migration on a bottleneck we had characterised from one pathological query and a hunch, and never measured across the system.
The rule
Instrument first, then decide. If we'd had request tracing switched on before we started, we would have seen the CPU-bound share of our workload in an afternoon, and we would not have done any of this.
The second-order version is more useful, because "measure first" is advice everybody already nods at. It's this: we picked the intervention whose appeal was that it required no decisions. "Change the runtime, keep the code" promised a platform-wide win with no architectural work, no interface to design, no component to carve out. That property should have been the warning. A change that requires you to understand nothing about your system will not be informed by anything you know about your system.
The narrower fix — take the one CPU-bound component out into a service built for it — looked like more work. It was more work. It would also have been scoped, reversible, and paid for by a measurement.
Seven years later
A footnote, because a lesson you can't show yourself applying isn't a lesson.
Immediately afterwards we turned on application performance monitoring with custom instrumentation on exactly the request path we'd been guessing about, and I later pulled our metrics reporting out into a small standalone library so instruments were cheap to add.
In 2023, when we wanted to replace a dataframe library, I benchmarked the candidates for time and memory, inflated the dataset a hundredfold to find where the incumbent fell over, and published the numbers before anyone migrated. When a distributed rewrite of our ingestion pipeline claimed a twentyfold speedup, I ran a live channel and a clone of it through the old and new pipelines and compared the rendered dashboards before merging the change.
And in a compiler I'm working on now, the repository carries its measured numbers with a note saying when they were taken and not to re-derive them — alongside the hypotheses the data disproved, written down so nobody pays for them twice.
There is one more turn of the screw. The forward-looking recommendation in this postmortem was to carve the CPU-bound work out into a dedicated service. We did — it became a Go service over an Apache Drill cluster. In 2024 I deleted it, and the four-node non-preemptible pool underneath it, by making the in-process path faster than the service. The 2019 conclusion was right in 2019 and wrong by 2024, and noticing that took longer than it should have.
That last habit is the one I'd actually recommend. Recording what you concluded is normal. Recording what you ruled out is what stops a team relitigating a dead idea every eight months — and it's the thing I wish someone had left behind for me in 2018.
The migration went in during August 2018 and came out in February 2019. The quoted maintainer response and the code samples are from the internal write-up I did at the time.