LaunchKit
← All posts
· 9 min read · by The LaunchKit team · 13 views

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.

#rails #performance #deployment

Comments

No comments yet. Be the first.

Only used to confirm and publish your comment. Never shown publicly, never shared.

Markdown: **bold**, `code`, ```fenced blocks```, > quotes, [links](url). HTML and images are not rendered.