Isometric illustration of a Puma master process feeding four worker blocks, each running several thread columns

Puma workers and threads decide how many requests a Rails server handles at once. Workers are separate Ruby processes that run Ruby code in parallel on different CPU cores. Threads inside each worker let it keep serving while other requests wait on the database or an API. The starting point for most apps is one worker per CPU core available to the container and three threads per worker (the Rails 7.2+ default), with the number of workers capped by memory. This guide explains why those numbers work, how to adjust them for your app, the container and platform traps that break the rule, what changed in Puma 7 and 8, and how to prove your settings with a load test.


TL;DR

  • Workers (WEB_CONCURRENCY) give you CPU parallelism. Start with one per CPU core actually available to the container, then lower it until workers × memory per worker fits in about 80% of RAM.
  • Threads (RAILS_MAX_THREADS) let a worker overlap I/O waits. Rails 7.2+ defaults to 3, a good balance between throughput and latency for typical apps.
  • Inside Docker, Etc.nprocessors reports the host’s cores, not the container’s CPU quota. Use Concurrent.available_processor_count or set WEB_CONCURRENCY explicitly.
  • Keep the database pool equal to threads, and count total connections across every worker and server.
  • Puma 7 renamed hooks (on_worker_boot → before_worker_boot) and enables preload_app! by default in cluster mode. Puma 8 binds to :: when IPv6 is available.

Table of Contents

How do Puma workers and threads work?

In cluster mode, a Puma master process loads the app and forks workers. Each worker is a full Ruby process with its own Global VM Lock (GVL), so workers run Ruby code truly in parallel. Threads inside a worker share one GVL: only one runs Ruby at a time, but the others can wait on I/O meanwhile.

Puma cluster mode: master forks workers, each with its own GVL and a few threads

That’s why the two settings solve different problems:

  Workers Threads
What they are Separate OS processes Threads inside one process
Run Ruby in parallel? Yes, one per core No, they share the GVL
Help with CPU-bound work (rendering, serialization) I/O waits (database, HTTP calls, Redis)
Cost Memory: each one is a full Rails process Small memory, more GVL contention and latency
Setting WEB_CONCURRENCY → workers RAILS_MAX_THREADS → threads
Failure isolation A crashed worker is restarted by the master A stuck thread blocks one request slot

Maximum concurrent requests per server = workers × threads. Four workers with three threads handle twelve requests at once. A thirteenth waits in Puma’s backlog.

How many Puma workers should I run per CPU core?

Run about one worker per CPU core available to the process, and reduce that number if memory runs out. More workers than cores just compete for CPU. Fewer leaves cores idle when traffic is CPU-bound.

The two limits, in order:

  1. CPU: workers ≈ cores. With preload_app!, workers share loaded code through copy-on-write, so the memory cost of each extra worker is lower than a separate process would be.
  2. Memory: workers × RSS per worker + master ≤ ~80% of RAM. A typical Rails worker uses 200–500 MB after warm-up. On a 2 GB instance with 400 MB workers, that’s four workers at most, even with eight cores.

Heroku’s own recommendations show the memory limit at work: one worker (two if memory allows) on 512 MB dynos, two on Standard-2X and Performance-M, and eight on Performance-L, all with three threads.

Pro Tip: On Puma 7+, consider fork_worker. Workers are then re-forked from an already-warmed worker 0 instead of the master, which improves copy-on-write sharing after warm-up and reduces memory per worker for apps with lots of lazily-loaded code.

How many threads should each Puma worker use?

Use 3 threads per worker unless measurements tell you otherwise. Rails changed its default from 5 to 3 in Rails 7.2 because, with the GVL, more threads add throughput only when requests spend most of their time waiting on I/O, and they increase latency for everyone else.

App profile Time waiting on I/O Threads per worker
CPU-heavy (rendering big pages, JSON serialization, image work in-request) < 25% 1–2
Typical CRUD app with PostgreSQL 25–50% 3 (default)
I/O-heavy (calls to external APIs, slow storage) > 50% 5–8, and fix the slow calls

More threads don’t make one request faster. They let more requests wait at the same time. If your requests mostly wait on a slow API, fixing the timeout and moving the call to a job, as described in the Rails request timeout guide, beats adding threads.

Prerequisites

  • Rails 7.2+ (examples use Rails 8.0/8.1, Ruby 3.3 with YJIT, Puma 7 or 8).
  • A staging environment that matches production instance size, for load testing.
  • A load-testing tool: hey, oha or k6.

Step 1: Write a production config/puma.rb

Read every concurrency setting from the environment, so you can tune per instance size without code changes. This config works with Puma 7 and 8:

# config/puma.rb
require "concurrent"

# Threads per worker. Rails 7.2+ default is 3.
threads_count = ENV.fetch("RAILS_MAX_THREADS", 3).to_i
threads threads_count, threads_count

# Workers: explicit WEB_CONCURRENCY wins; otherwise use the CPUs the container may use.
default_workers = [Concurrent.available_processor_count.floor, 1].max
workers ENV.fetch("WEB_CONCURRENCY", default_workers).to_i

# Load the app once in the master, then fork (default in cluster mode since Puma 7).
preload_app!

port ENV.fetch("PORT", 3000)
environment ENV.fetch("RAILS_ENV", "development")
pidfile ENV.fetch("PIDFILE", "tmp/pids/server.pid")

# Restart a worker that stops checking in with the master for 60 s.
worker_timeout 60

# Allow `bin/rails restart`.
plugin :tmp_restart

# Run Solid Queue inside Puma on small apps.
plugin :solid_queue if ENV["SOLID_QUEUE_IN_PUMA"]

# Local control socket for `pumactl stats`.
activate_control_app "unix://#{File.expand_path("../tmp/pumactl.sock", __dir__)}", { no_token: true }

Two things you no longer need:

  • on_worker_boot { ActiveRecord::Base.establish_connection }. Rails reconnects automatically after fork. Puma 7 also renamed the hook to before_worker_boot and deprecated the old name.
  • Single-mode special cases for tiny instances. If WEB_CONCURRENCY is 0 or 1 on very small instances, consider workers 0 (single mode). It saves the master’s memory, at the cost of zero-downtime phased restarts.

Align the database pool with the thread count:

# config/database.yml (excerpt)
production:
  primary:
    url: <%= ENV["DATABASE_URL"] %>
    pool: <%= ENV.fetch("RAILS_MAX_THREADS", 3) %>

Total connections are workers × threads per server, for every database the process uses, as explained in the connection pool guide.

Step 2: Measure memory per worker

Measure each worker’s real memory after it has served traffic for a while. Freshly booted workers look much smaller than they’ll be in an hour.

# On the server or inside the container
ps -o pid,rss,cmd -C ruby --sort=-rss | awk 'NR==1 || /puma/ {printf "%-8s %6d MB  %s %s %s %s\n", $1, $2/1024, $3, $4, $5, $6}'
# PID        RSS
# 4127       382 MB  puma: cluster worker 0: 1 [app]
# 4131       376 MB  puma: cluster worker 1: 1 [app]
# 4110       141 MB  puma 7.1.0 (tcp://[::]:3000) [app]

Then do the arithmetic for your instance:

usable RAM      = instance RAM × 0.8
workers (max)   = (usable RAM − master RSS) ÷ worker RSS after warm-up

With 2 GB: (1638 MB − 141 MB) ÷ 382 MB ≈ 3.9, so 3 workers, leaving headroom for growth during the day. If that’s fewer workers than you have cores, you’re memory-bound. jemalloc (already preloaded by the Rails 8 Dockerfile) often reduces worker RSS a lot. The memory leak guide shows how to confirm it’s loaded.

Step 3: Detect CPUs correctly in containers

Inside a container with a CPU limit, Ruby’s Etc.nprocessors returns the number of host CPUs, not the container’s quota. On a 32-core host running your container with --cpus=2, it says 32, and Puma would fork 32 workers into a 2-CPU, memory-limited container.

docker run --rm --cpus=2 ruby:3.3-slim ruby -retc -e 'puts Etc.nprocessors'
# 32  (host CPUs)

docker run --rm --cpus=2 ruby:3.3-slim bash -c 'gem install concurrent-ruby -q && ruby -rconcurrent -e "puts Concurrent.available_processor_count"'
# 2.0 (container quota)

Concurrent.available_processor_count (concurrent-ruby 1.3+, already a Rails dependency) reads the cgroup CPU quota, which is why the puma.rb above uses it. On platforms with fractional CPUs it can return values like 0.5, hence the floor and max 1. When in doubt, set WEB_CONCURRENCY explicitly per instance type. It’s the one setting you always want under your control.

Step 4: Load-test and adjust

Load-test a staging server of the same size as production, with realistic pages, and compare throughput and p95 latency for a few worker/thread combinations. The right setting is the one with the best p95 at your expected peak traffic, not the highest throughput.

# Concurrency = workers × threads, e.g. 3 × 3 = 9
hey -z 60s -c 9  -H "Cookie: _myapp_session=…" https://staging.example.com/dashboard
hey -z 60s -c 18 -H "Cookie: _myapp_session=…" https://staging.example.com/dashboard

Try these combinations, one at a time, by changing environment variables and restarting:

Run WEB_CONCURRENCY RAILS_MAX_THREADS Watch
A cores 3 Baseline
B cores 5 Does throughput rise without hurting p95?
C cores 1 Is p95 much better? Your app may be CPU-bound
D cores − 1 3 Does memory headroom matter more than the extra worker?

For each run, record requests per second, p50, p95 and p99 from the tool’s output, along with CPU and memory per worker. Typical patterns:

  • Throughput flat, p95 up when adding threads: CPU-bound. Keep threads at 2–3.
  • Throughput up, p95 stable with more threads: I/O-bound. 5 threads may be worth it, if the database pool and max_connections allow it.
  • Memory near the limit: fewer workers, and look at memory per worker before anything else.

Step 5: Verify it works

Confirm that production runs the worker and thread counts you chose, and that requests aren’t queueing.

bundle exec pumactl --control-url "unix://$(pwd)/tmp/pumactl.sock" stats | ruby -rjson -e '
  s = JSON.parse(STDIN.read)
  puts "workers: #{s["booted_workers"]}/#{s["workers"]}"
  s["worker_status"].each { |w| l = w["last_status"]; puts "  worker #{w["index"]}: running=#{l["running"]} capacity=#{l["pool_capacity"]} max=#{l["max_threads"]} backlog=#{l["backlog"]}" }'
# workers: 3/3
#   worker 0: running=3 capacity=3 max=3 backlog=0
#   worker 1: running=3 capacity=1 max=3 backlog=0
#   worker 2: running=3 capacity=2 max=3 backlog=0
  • backlog greater than 0 for long stretches means requests are waiting for a free thread. You need more capacity: more workers if CPU allows, otherwise more instances.
  • pool_capacity at 0 on all workers during normal traffic means every thread is busy all the time, which is the same conclusion.

In production, your APM’s request queue time tells the same story across all servers. Keep it in single-digit milliseconds at normal load. When it isn’t, add capacity: more workers if CPU and memory allow, otherwise more instances.

How Heroku, Render, Fly.io, Upsun and Enkihost affect Puma sizing

The instance’s CPU allocation and memory decide your Puma settings, and each platform presents those differently. Shared CPUs in particular make “one worker per core” misleading.

Platform CPU you get Memory Sets WEB_CONCURRENCY? Practical starting point
Heroku Shared on Eco/Basic/Standard; dedicated on Performance 512 MB – 14 GB+ No, you set it Heroku’s table: 1 (512 MB), 2 (Standard-2X, Perf-M), 8 (Perf-L); 3 threads
Render 0.5 (Starter), 1 (Standard), 2 (Pro) CPUs 512 MB – 4 GB+ No 1 worker on Starter/Standard, 2 on Pro; 3 threads
Fly.io Shared vCPU: 6.25% baseline + burst; performance vCPU: dedicated Chosen per Machine No 1 worker per shared vCPU, 1 per performance vCPU; watch throttling
Upsun CPU set per container with upsun resources:set Set per container No Workers ≈ allocated CPUs, capped by container memory
Kamal / VPS Whole machine (or your container limits) Whole machine No Cores, capped by memory; use available_processor_count
Enkihost Ignite: 1.0 vCPU; Blaze: 2 dedicated vCPUs Ignite: 2 GB shared by up to two apps; Blaze: 4 GB Not documented 1 worker, 3 threads on Ignite; 2 workers on Blaze, capped by memory

The Fly.io case shows why measurement beats rules. A shared-cpu-2x Machine has two vCPUs, but each is guaranteed only 6.25% of a core, with burst credits on top. Two workers there run fast during bursts and get throttled under sustained load. For steady traffic, a performance Machine with fewer workers can beat a shared one with more. CPU throttling is covered in detail in the Rails performance optimization guide.

Enkihost allocates memory and CPU per app with per-app resource isolation, so you size WEB_CONCURRENCY against a known allocation, and the numbers you measure don’t shift with other tenants’ traffic.

Troubleshooting Puma configuration

ActiveRecord::ConnectionTimeoutError under load

ActiveRecord::ConnectionTimeoutError: could not obtain a connection from the pool within 5.000 seconds (waited 5.000 seconds); all pooled connections were in use

RAILS_MAX_THREADS is higher than the database pool, or something else in the process (Solid Queue in Puma, load_async) uses connections too. Set pool from the same variable, plus headroom for those extra threads.

Terminating timed out worker

[4110] ! Terminating timed out worker (Worker 1 failed to check in within 60 seconds): 4131

A worker stopped responding to the master for 60 seconds. That’s a whole process stuck, not a slow request: a deadlock, a C extension hanging, or the machine swapping heavily. Check memory first. Then use rbspy dump --pid <worker pid> or bundle exec pumactl --control-url unix://$(pwd)/tmp/pumactl.sock thread-backtraces to see where it’s stuck.

Memory errors right after a deploy

R14 on Heroku or OOM kills elsewhere, immediately after boot, mean too many workers for the instance. On containers, check that Etc.nprocessors isn’t being used to pick the worker count (see Step 3). Set WEB_CONCURRENCY explicitly.

WARNING: Detected N Thread(s) started in app boot

! WARNING: Detected 2 Thread(s) started in app boot:
! #<Thread:0x… config/initializers/metrics.rb:3 sleep> - …

Something started a thread while the app loaded in the master (a metrics client, a background poller). Threads don’t survive fork, so that work silently stops in every worker. Start such threads in a before_worker_boot hook (Puma 7+) or lazily on first use.

Deprecation warnings for on_worker_boot after upgrading Puma

Puma 7 renamed hooks and deprecated the old names: on_worker_boot → before_worker_boot, on_worker_shutdown → before_worker_shutdown, on_restart → before_restart, on_booted → after_booted. Rename them, or delete them if they only re-established Active Record connections.

Author Perspective: tune for p95, not for a benchmark

I used to tune Puma for the biggest requests-per-second number in a load test, and the result was always more threads. In production that showed up as worse tail latency: with the GVL, every extra thread waiting to run Ruby delays everyone else’s response a little. These days I start with the Rails defaults, cores-many workers and three threads, measure memory after a day of real traffic, and change only one number at a time, judging by p95 at peak hours. Nine times out of ten, the change that helps is fewer workers on a memory-starved instance, or fixing the slow endpoint that holds a thread for two seconds. A higher thread count rarely is.

Running Puma on Enkihost

Puma sizing is arithmetic on CPU and memory, so it’s easiest when those numbers are fixed and yours alone. On Enkihost:

Enkihost

  • Allocated memory and CPU per app, so WEB_CONCURRENCY is sized against a known budget instead of guessing at a shared host’s free memory.
  • Per-app resource isolation: other apps’ traffic doesn’t steal the CPU your workers were sized for, so load-test results hold in production.
  • Zero-downtime deploys, so changing WEB_CONCURRENCY or RAILS_MAX_THREADS and redeploying doesn’t drop requests.
  • PostgreSQL and Redis add-ons that inject DATABASE_URL and REDIS_URL, with the pool sized from the same RAILS_MAX_THREADS variable.

Rails and Sinatra apps run on Ignite (9 EUR per month after a 14-day free trial, with PostgreSQL and Redis included) or Blaze (29 EUR per month, with high availability and autoscaling). The free Spark plan is for Jekyll sites. See enkihost.com.

FAQ

What is the default number of Puma threads in Rails?

Since Rails 7.2, new apps default to 3 threads per Puma worker, set through RAILS_MAX_THREADS in config/puma.rb. Earlier versions defaulted to 5. The change was made because fewer threads give better latency for typical Rails apps, where the GVL limits how much extra threads help.

Should I use Puma workers or threads?

Use both. Workers give true parallelism across CPU cores, and threads let each worker keep serving while other requests wait on the database or network. A common production setup is one worker per available CPU core, limited by memory, with three threads per worker.

How much memory does each Puma worker use?

A typical Rails app uses between 200 and 500 megabytes per Puma worker after warm-up, depending on gems, YJIT and the work requests do. Measure the resident memory of each worker after several hours of real traffic, because freshly booted workers are much smaller.

Should I run Puma in single mode or cluster mode?

Use cluster mode with at least two workers whenever memory allows, because a crashed or slow worker doesn’t take down the whole server, and you get parallelism across cores. Single mode is reasonable on very small instances, around 512 megabytes, where the master process’s memory would be better spent on the app itself.

Does WEB_CONCURRENCY need to match the CPU count exactly?

No, it is a starting point. Use the number of CPUs actually available to the container, then reduce it if memory is tight, or adjust it based on load tests. On shared-CPU instances with throttling, fewer workers often perform better under sustained load than the vCPU count suggests.

Sources