Three threads, not five: what the GVL actually costs you
Rails ships a default most people never revisit. Here's how CRuby's global lock decides your thread count for you, and the arithmetic for picking workers.
Every Rails app I have inherited had the same config/puma.rb: five threads, workers
set to whatever the last person guessed, and nobody able to explain either number. It
is the most consequential file almost nobody tunes, and the defaults changed under
most people's feet — the Rails guides now recommend **three** threads per process, not
five.
The reason is the Global VM Lock, and understanding it properly makes both numbers fall out almost mechanically.
What the GVL actually does
CRuby lets many threads exist but only one execute Ruby bytecode at a time. A thread holds the lock while running Ruby, and releases it when it blocks on something outside the interpreter — a socket read, a database round trip, a file operation, an HTTP call to a third party.
So threads in CRuby do not buy you parallel computation. They buy you the ability to use the gaps. While thread A waits on Postgres, thread B can run Ruby. That is the entire value proposition, and it means the useful thread count is a function of one thing: **what fraction of a request is spent outside Ruby.**
A well-behaved Rails request spends roughly half its time on I/O. With a 50% I/O share, a second thread has plenty of gap to fill. A third has some. By the fourth and fifth the gaps are gone — the lock is held nearly all the time, and the extra threads do something actively harmful.
Why extra threads make things worse, not neutral
This is the part people miss. Unused threads are not free capacity sitting idle.
Puma will happily accept a request onto any free thread. If a worker has five threads and only enough gap to usefully run two, it still accepts five requests. Three of them now sit inside the process, holding memory, waiting their turn for a lock they cannot get — while the load balancer, seeing the request accepted, sends nothing elsewhere.
Extra threads do not add throughput once the lock is saturated. They convert queue time you could see into latency you cannot.
You have not increased capacity. You have moved the queue from a place you can observe — in front of your servers — to a place you cannot, inside the process. Your p50 barely moves and your p99 gets noticeably worse, which is exactly the shape of "we added threads and it felt slower" that people report and then disbelieve.
So: three, unless you can show otherwise
Three is the sane default because it fits the ~50% I/O profile of a typical app. Go higher only with evidence, and the evidence is specific: what share of your request time is spent outside Ruby?
Raise it when you are genuinely I/O-dominated — a controller that spends 80% of its time waiting on a slow third-party API can justify more threads, because the gaps are enormous. Lower it when you are compute-heavy, doing serialisation, image work or anything numeric in pure Ruby, where even the second thread finds little room.
The measurement worth taking is the GVL wait time, not CPU. There are gems that instrument the lock directly; without one, a decent proxy is comparing total request duration against time spent in database and external calls. If those add up to most of your request, threads help. If they do not, they will not.
Workers: the number that actually buys parallelism
Processes are where real concurrency lives, because each has its own lock. The starting formula is straightforward:
**Workers = CPU cores available to the app**, when you are running multiple threads per process. If you are running single-threaded, you can go slightly above core count — around 1.3 to 1.5 per core — because processes will sit blocked on I/O and cores go idle otherwise.
Then memory decides whether you can afford it. Each worker is a full copy of your application. The real constraint is almost never "how many cores do I have" but "how many copies of a 400MB process fit in this container before the OOM killer takes an interest".
Two practical notes. WEB_CONCURRENCY=auto asks Puma to count processors for you,
which is convenient and frequently wrong on cloud hosts — containers routinely see the
host's core count rather than their own CPU allocation. And if you are on a platform
that reports memory quota errors, worker count is the first dial to turn down, not the
last.
Preloading, and the copy-on-write payoff
In clustered mode, preload_app! loads your application before forking. Every worker
then inherits the parent's memory pages, and the operating system only makes a private
copy when a page is written to.
For a Rails app, a large share of resident memory is loaded code and constants that are never mutated after boot. Those pages stay shared. The saving grows with worker count — the difference between four workers and eight is much less than double, which is what makes larger pools economically reasonable at all.
The catch is anything holding a file descriptor across the fork. Database connections
must be re-established per worker; that is what on_worker_boot is for, and forgetting
it produces the classic symptom of workers mysteriously sharing and corrupting a
connection.
What I actually run
For a conventional Rails app on a container with two dedicated cores: two workers, three threads each, preloading on. That is six concurrent requests in flight, real parallelism across two cores, and a memory profile I can predict.
For the site you are reading this on, the answer is different and deliberately so — it uses SQLite, which allows exactly one writer at a time. Adding workers there does not add capacity, it adds processes contending for a write lock. So it runs a single process with a small thread pool, and the "wrong" configuration by conventional standards is the right one for the storage engine underneath.
That is the real lesson. There is no universally correct Puma config. There is a correct one for your I/O profile, your core count, your memory ceiling and your database's concurrency model — and four of those five are things you can measure this afternoon.