What Puma is doing in your Rails app
Puma is a threaded web server, and the part worth understanding is not the server, it is the two numbers Rails picks for you and what each one can and cannot buy. One of them is free throughput on most Rails applications. The other does nothing at all on some of them, and the difference is visible in a benchmark you can run in an afternoon.
Everything below was measured on 2026-10-03 against puma 8.0.2, Rails 8.1.4 and Ruby 4.0.5, on a
12 logical core machine, with the server and the load generator on the same box. 24 or 120 requests
per run at the concurrency stated, through a Ruby client using Net::HTTP and plain threads.
What the generated config actually says
The whole of config/puma.rb from rails new, minus its comments:
threads_count = ENV.fetch("RAILS_MAX_THREADS", 3)
threads threads_count, threads_count
port ENV.fetch("PORT", 3000)
plugin :tmp_restart
pidfile ENV["PIDFILE"] if ENV["PIDFILE"]
Three things are notable and two of them are absences.
Min equals max. threads 3, 3 rather than threads 0, 3. A pool that never shrinks, so thread
creation is never on a request's critical path and memory is predictable.
There is no workers line. The file's own comment explains why: "The default is already 1."
There is no preload_app!. Which means that when you do set workers, each one boots the
application for itself.
The comment block is also where Rails tells you the rule that the rest of this page is about: "due to CRuby's Global VM Lock (GVL) it has diminishing returns". It is more absolute than diminishing, on the wrong workload.
Two endpoints, deliberately
class LoadController < ActionController::Base
def io # what a database call looks like to Ruby: the GVL is released
sleep 0.1
render plain: "io #{Process.pid}"
end
def cpu # pure Ruby: the GVL is held
n = 0
300_000.times { n += Math.sqrt(n + 1) }
render plain: "cpu #{Process.pid}"
end
end
sleep is a fair stand in for a query or an HTTP call: CRuby releases the GVL around it, so other
threads run. Math.sqrt in a Ruby loop does not release anything.
Returning the pid is how the client counts how many processes answered, which is the check that a worker configuration actually forked.
Threads, on IO
24 requests, concurrency 12, production:
| Threads | Throughput |
|---|---|
| 1 | 9.4 req/s |
| 3 | 28.2 req/s |
| 12 | 103.3 req/s |
One thread and a 100 ms sleep has a ceiling of 10 req/s, and 9.4 is that ceiling. Three threads is three times it. Twelve threads is eleven times it. This is the case the Rails default is tuned for, and if your application spends its time waiting on Postgres, this is your curve.
Threads, on CPU
Same harness, same concurrency, the other endpoint:
| Threads | Throughput |
|---|---|
| 1 | 57.6 req/s |
| 3 | 56.0 req/s |
| 12 | 57.7 req/s |
There is no trend in that column. Twelve threads is not slower in any meaningful way and it is not faster in any way at all. One Ruby process executes one Ruby instruction stream, and twelve threads queueing for the same lock produce the throughput of one thread holding it.
This is the number to have in your head the next time somebody proposes raising
RAILS_MAX_THREADS to fix a slow page. If the page is slow because it is computing, the lever does
nothing.
Workers, on CPU
WEB_CONCURRENCY=4 RAILS_MAX_THREADS=3 -> 148.4 req/s, 4 processes
2.6x the single process figure rather than 4x, on a machine that also had the load generator and the Puma master on it, and over a 24 request sample where fork scheduling is a visible fraction of the run. The direction is the point: separate processes have separate GVLs, so CPU bound work scales with processes and only with processes. How many you can afford to run is a memory question, and on a single box it is the same arithmetic as in Running Rails on a VPS.
For IO, the split does not matter
This one corrected an earlier reading of mine. On a 24 request sample, four workers of three threads looked clearly worse than twelve threads in one process. At 120 requests and concurrency 24 the gap disappeared:
| Configuration | Concurrent slots | Throughput |
|---|---|---|
w=0 t=1 |
1 | 9.4 req/s |
w=0 t=12 |
12 | 106.7 req/s |
w=4 t=3 |
12 | 100.9 req/s |
w=4 t=12 |
48 | 220.0 req/s |
Twelve slots is about a hundred requests a second whichever way you arrange them, and the small sample had been measuring queue quantisation rather than a throughput ceiling. Worth stating plainly because it is the kind of difference that gets written up as a finding.
The 48 slot row stops being linear, which is where the sleep stops being the only constraint and the machine starts being one.
One thing the generated config's comment warns about and this table would hide: every one of those
slots wants a database connection. "Any libraries that use a connection pool should be configured to
provide at least as many connections as the number of threads." Four workers of twelve threads is 48
connections, per host, and Postgres has a max_connections.
WEB_CONCURRENCY=auto
The generated comment mentions it, and nothing in the Rails config implements it, because Puma does.
puma-8.0.2/lib/puma/configuration.rb:248:
workers_env = env['WEB_CONCURRENCY']
workers = workers_env && workers_env.strip != "" ? parse_workers(workers_env.strip) : nil
and parse_workers:
def parse_workers(value)
if value == :auto || value == 'auto'
require_processor_counter
Integer(::Concurrent.available_processor_count)
else
Integer(value)
end
rescue ArgumentError, TypeError
raise ArgumentError, "workers must be an Integer or :auto"
end
Measured on the 12 core machine, Puma's banner printed * Workers: 12.
The identifier is worth reading twice: available_processor_count, not processor_count. The two
are different methods in concurrent-ruby and they answer differently where a CPU quota is in force,
which is every container. Check which number yours reports inside the container rather than on your
laptop, because auto on a laptop and auto in a 1 CPU container are not the same decision.
require_processor_counter warns and re-raises if concurrent-ruby is missing. Any Rails app has it
through Active Support, so this is only a hazard in a bare Rack application.
Development is not the bottleneck people say it is
The received wisdom is that development serialises requests, so threads are pointless there. Measured, same endpoint, same concurrency:
| Threads | Development | Production |
|---|---|---|
| 1 | 9.3 req/s | 9.4 req/s |
| 3 | 26.8 req/s | 28.2 req/s |
| 12 | 87.1 req/s | 103.3 req/s |
Development scales. It costs about 16 percent at twelve threads, with enable_reloading = true and
eager_load = false, after a warm up request. There is a load interlock and it is not a mutex
around the whole request.
What that changes in practice: if your development server feels like it handles one request at a time, the reloader is probably not why, and the next place to look is a single connection in the database pool or something in the app holding a lock.
The dead end, which is also a warning about benchmarks
The first development run reported 16.3 req/s at one thread, on an endpoint that sleeps 100 ms. One thread and a 100 ms sleep cannot exceed 10 req/s. The number was impossible, which is the only reason it got looked at.
It was serving a 500 in 55 ms. The cause:
# app/controllers/probe_controller.rb
class ProbeController < ActionController::Base
# ...
end
class LoadController < ActionController::Base # second constant, same file
# ...
end
Production eager loads every file under app/, so LoadController got defined as a side effect and
worked. Development autoloads by constant name, so a request for LoadController asks Zeitwerk for
app/controllers/load_controller.rb, which did not exist, and the response was an exception page
that the benchmark counted as a successful request.
Two things to take from it. One file, one constant, and the environment that forgives you is
production, which is the wrong way round for finding out. And a load generator that does not assert
the status code is a load generator that will happily measure your error page: the harness now
checks http_code before every run, and every number on this page was taken with http=200 on the
record.
preload_app! measured nothing
Worth reporting as a null result rather than quietly leaving out.
| Configuration | Time to first successful request | Workers booted |
|---|---|---|
w=4, no preload_app! |
0.98s | 4 |
w=4, preload_app! |
0.98s | 4 |
w=0 |
0.97s | 0 |
Identical. The application under test is a rails new --minimal skeleton with four controllers, so
there is essentially nothing to preload and nothing to share. preload_app! boots the app once and
forks, which is what enables copy on write memory sharing and cuts boot to a single load, and both
of those effects scale with how much your application loads at boot. This measurement says nothing
about that, and a number from this app would have been a number about nothing.
What this page does not cover
Memory. The most important practical reason to care about workers against threads is resident set size per process and whether copy on write holds under a real heap, and an attempt to measure RSS per worker here produced numbers that could not be reconciled with the master process, so none of them are published.
Also absent: latency. Every table above is throughput, and the GVL's real cost on a mixed workload
shows up as p99 response time rather than as requests per second, which needs a load generator that
records percentiles instead of a wall clock. Nothing ran behind nginx, Thruster or a load balancer, which is where a real deployment puts it and
which Deploying Rails with Kamal covers from the other side,
so queue_requests, keepalive and the persistent_timeout that
puma/configuration.rb reads from PUMA_PERSISTENT_TIMEOUT were never exercised. No run used a
database, so connection pool exhaustion is named above as arithmetic and was never actually hit.
Comments
No comments yet. Be the first.