YJIT is free performance, until you read the memory graph
15–30% faster for a flag you probably already have on. What it costs per worker, and the one case where you should turn it down rather than off.
YJIT is the rare optimisation that is genuinely close to free. Recent Rails enables it by default on modern Ruby, most applications see somewhere between 15% and 30% better throughput, and the code change required is none.
Which is exactly why it is worth understanding the part that is not free.
What it does, briefly
YJIT is a just-in-time compiler. Ruby starts by interpreting bytecode; YJIT watches which code actually runs, and compiles the hot paths to machine code specialised to the types it has observed.
That specialisation is where the win comes from. An interpreter handling a + b must
check at runtime what a and b are and dispatch accordingly, every single time. If
YJIT has seen that this call site always receives integers, it emits code that assumes
integers, with a guard that bails out to the interpreter if something unexpected turns
up.
Rails benefits disproportionately because framework code is enormously polymorphic in principle and boringly monomorphic in practice. The same call sites run millions of times with the same shapes.
The cost is memory, and it is per worker
Compiled code lives somewhere. YJIT keeps an executable memory region per process, and that is on top of your normal heap.
Budget roughly 30–50MB per worker. On a handful of workers this is unremarkable. On twenty, it is meaningfully into gigabyte territory — and that is precisely the configuration where people enable YJIT for throughput and then find themselves fighting the OOM killer, without connecting the two events.
The good news is that the trend has been strongly positive. Each release has compiled more code while using less memory to do it; Ruby 3.4's YJIT is both faster than 3.3's and lighter. If you evaluated YJIT a couple of versions ago and rejected it on memory grounds, that conclusion is probably stale.
Turn it down before you turn it off
This is the point most worth taking away.
When YJIT's memory shows up as a problem, the reflex is to disable it and give back the entire performance win. There is a dial in between: the executable memory size limit. Lower it and YJIT compiles the hottest code and stops, rather than compiling everything.
Because the distribution of hot code is steep — a small fraction of call sites account for most execution — a substantially smaller budget typically retains most of the benefit. Halving the memory does not halve the speedup, it costs a slice of it.
Reach for the dial before the switch. Most of the win lives in a small part of the budget.
How to actually evaluate it
Three things, in order.
Measure throughput on your real workload, not a benchmark. YJIT's advantage varies with how much time you spend in Ruby versus waiting on I/O. An app that spends most of its request blocked on a database sees less benefit than one doing heavy serialisation, because there is simply less Ruby to speed up.
Watch RSS per worker before and after, and multiply by your worker count. This is the number that determines whether it fits.
Give it warm-up time. YJIT compiles as it observes; the first requests after a deploy run interpreted. Benchmarking a cold process measures the wrong thing, and a short-lived container that restarts constantly pays the warm-up repeatedly and collects less of the reward.
What "warm" actually means, and why short containers lose
YJIT compiles what it observes. That means there is a period after every process start where your code runs interpreted, then progressively faster as hot paths get compiled.
Three practical consequences follow.
Benchmarks taken immediately after boot measure the interpreter, not YJIT, and will tell you the feature does nothing. Warm the process first — send it real traffic patterns for long enough that the hot paths have been seen — and only then measure.
Deploys briefly cost you performance, because every new worker starts cold. On a rolling deploy with a large pool this is usually invisible; on a small one under load it can be a noticeable dip that people misattribute to the release.
And a container that restarts frequently pays the warm-up repeatedly while collecting less of the reward. If you are aggressively scaling to zero, or your processes are being recycled every few minutes by a memory limit, you are buying a compiler and throwing away most of what it produces. Fixing the churn matters more than the JIT setting.
Where it does not help
If your bottleneck is the database, YJIT will not move it. If you are I/O-bound on external APIs, it will not move that either. It makes Ruby faster, and only that. When Ruby is not what you are waiting on, the honest answer is that it will do very little for you — and finding that out from a measurement is much cheaper than finding it out from a memory graph after a deploy.