How to Load Test a Rails App

To load test a Rails app, size Puma as workers × threads. Use one worker process per CPU core, because Ruby's GVL lets each process use only one core for Ruby code, and around 3 threads per worker (the Rails default) to overlap I/O. Make the Active Record pool at least as large as the thread count in every process, including Sidekiq. Run with YJIT on, script the test with a real session cookie and authenticity token, and watch Puma's backlog and connection pool waits. Those show saturation before errors do.
How Rails handles concurrent requests
Rails apps almost always run on Puma, which combines processes and threads:
- Workers are forked processes. Rails' generated
config/puma.rbreadsWEB_CONCURRENCY, defaults to 1, and acceptsautoto start one worker per available processor. - Threads run inside each worker. The generated config uses
threads ENV.fetch("RAILS_MAX_THREADS", 3). The comment explains that 3 is "a decent compromise between throughput and latency for the average Rails application".
CRuby has a Global VM Lock (GVL). Only one thread per process runs Ruby code at a time. Threads help while another thread waits on the database, Redis or an HTTP call, because waiting releases the GVL. They do not add CPU parallelism. The Rails performance tuning guide puts it plainly: a process "will never use more than a single CPU core". So:
- Processes give you parallel CPU. Plan about one per core.
- Threads give you overlap during I/O. More threads raise throughput up to a point, but each one also competes for the GVL, so latency gets worse.
What usually breaks first
Requests queue in Puma
When all threads in all workers are busy, new connections wait in the socket backlog. Nothing errors, but every queued request adds wait time before Rails even sees it. Rails' logged request time does not include this wait, so your logs say 80 ms while the client sees 900 ms. That gap between client latency and server-logged duration is the clearest sign of queueing.
Active Record connection pool timeouts
Each thread checks out its own connection. If the pool is smaller than the threads using it, extra threads wait up to checkout_timeout (5 s by default) and then raise:
ActiveRecord::ConnectionTimeoutError: could not obtain a connection from the pool within 5.000 seconds (waited 5.000 seconds); all pooled connections were in use
The most common cause is Sidekiq or a threaded background runner configured with more concurrency than its pool. Another is code that holds a connection while it waits on something else. See database connection pool exhausted.
The database, through N+1 queries
A view that renders 30 products and calls product.brand.name in the loop runs 31 queries. Under load, each of those 31 round trips waits longer, and the thread and its connection stay busy for the whole page. Fix it with includes or preload. Rails' strict_loading (per model or per association) raises on lazy loads, which makes N+1s fail in your test suite instead of in production.
Memory growth per worker
Ruby processes grow over time from heap fragmentation, and YJIT adds memory for compiled code. Multiply worker RSS by workers per server. If that exceeds the container limit after an hour, you get OOM kills that a 5-minute test never shows. Run a soak test and watch RSS per worker. Many teams use jemalloc or set MALLOC_ARENA_MAX=2 to reduce fragmentation.
Settings to check before testing
config/puma.rb
threads_count = ENV.fetch("RAILS_MAX_THREADS", 3)
threads threads_count, threads_count
workers ENV.fetch("WEB_CONCURRENCY", 4) # one per CPU core
preload_app! # fork after boot: faster start, shared memory
port ENV.fetch("PORT", 3000)
plugin :tmp_restart
On Kubernetes, set WEB_CONCURRENCY explicitly from the pod's CPU allocation instead of auto. The Rails guide warns that automatic CPU detection can be inaccurate on shared cloud CPUs. The guide also gives two tuning directions: for latency, about 1 thread per process and roughly 1.3 to 1.5 processes per core; for throughput, one process per core with threads raised until latency becomes unacceptable.
config/database.yml
production:
adapter: postgresql
max_connections: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> # "pool:" in Rails 8.0 and earlier
checkout_timeout: 2
Rails 8.1 generates max_connections in place of the older pool key; both set the per-process pool size. The generated puma.rb states the rule directly: any library that uses a connection pool should provide "at least as many connections as the number of threads". Use the connection pool size calculator to check the cluster-wide total against PostgreSQL's max_connections (default 100).
YJIT and production mode
Rails 7.2 and later enable YJIT by default on Ruby 3.3 or newer. Confirm it is active with RubyVM::YJIT.enabled? in a production console. Also check RAILS_ENV=production, config.eager_load = true, and that config.log_level is not :debug. Debug logging writes every SQL statement for every request.
Sidekiq
Sidekiq's default concurrency is 5 threads per process. If you raise it to 20, its process needs a pool of at least 20, which is often missed because the web and job processes share one database.yml. Set the pool from an environment variable you also set on the Sidekiq deployment. If you use Solid Queue (the Rails 8 default), the same rule applies to its worker threads.
Rails tuning table
| Setting | Default | What to change | Why |
|---|---|---|---|
WEB_CONCURRENCY (Puma workers) | 1 in generated puma.rb | About 1 per CPU core | The GVL limits each process to one core of Ruby |
RAILS_MAX_THREADS | 3 | Keep 3; test 1 or 5 for latency vs throughput | More threads overlap I/O but add GVL contention |
max_connections / pool | RAILS_MAX_THREADS or 5 | ≥ threads in that process (web and jobs) | Prevents ConnectionTimeoutError |
checkout_timeout | 5 s | 1 to 2 s | Overload shows up quickly and clearly |
| YJIT | On (Rails 7.2+, Ruby 3.3+) | Keep on; tune --yjit-exec-mem-size if memory is tight | Less CPU per request |
preload_app! | Off unless configured | On | Copy-on-write memory sharing between workers |
| Sidekiq concurrency | 5 | From job I/O profile; match its pool | Job threads need connections too |
PostgreSQL max_connections | 100 | ≥ total of all pools + margin, or PgBouncer | Scaling out web pods multiplies connections |
What to put in the test scenario
- Uncached pages and API endpoints with varied IDs. Fragment and Russian-doll caching make a repeated single URL look far cheaper than real traffic. Test with a warm cache and a realistic hit rate, and once with a cold cache after deploy.
- Logged-in flows with the session cookie kept per virtual user. Logging in every iteration mostly measures bcrypt; see authentication load testing.
- Writes that take row locks and enqueue jobs: checkout, comments, uploads. For stores, ecommerce load testing has a typical mix.
- Turbo and Hotwire requests as separate endpoints. Frames and streams send extra requests per page view.
CSRF and the authenticity token
Rails protects non-GET requests with protect_from_forgery. Without a valid token, the request fails with ActionController::InvalidAuthenticityToken, usually as a 422. Each virtual user should:
- GET the form page and keep the session cookie (
_yourapp_session). - Extract
authenticity_tokenfrom the hidden form field, orcsrf-tokenfrom the<meta>tag. - Send it back as the
authenticity_tokenform parameter or theX-CSRF-Tokenheader.
API-only controllers that use token auth usually skip CSRF; send the Authorization header instead. Also check Rack::Attack throttles. A load generator with a few source IPs will trip them quickly, and 429s are a script problem, not a capacity result.
How to read Rails-specific signals
| Signal | Where | Meaning |
|---|---|---|
backlog above 0, pool_capacity at 0 | Puma.stats or pumactl stats | All threads busy; requests queuing before Rails |
| Client p95 far above Rails' logged duration | Load test vs logs or APM | Queue time in Puma or the proxy |
| Request time mostly "ActiveRecord" | Rails log line or APM | Database-bound: slow queries, N+1, locks |
| CPU at 100% per worker, low DB time | top, APM | Ruby-bound: views, serialization; add workers or cores |
| Sidekiq queue latency rising | Sidekiq Web UI | Job throughput below enqueue rate |
| Worker RSS growing steadily | Container metrics | Fragmentation or a leak; OOM later |
Rails' log line (Completed 200 OK in 182ms (Views: 41.2ms | ActiveRecord: 120.5ms ...)) already splits view time from database time. Compare it across load levels: if ActiveRecord time grows while view time stays flat, the database is the knee. The pattern is covered in latency increases under load.
Worked example: workers and threads for a target
An illustrative Rails app must serve 200 RPS. A typical request takes 120 ms of server time, of which 50 ms is Ruby (views, serialization) and 70 ms is waiting on PostgreSQL and Redis.
- CPU: Ruby time limits each process to about 1 ÷ 0.05 = 20 RPS at full use of one core. For 200 RPS that is 10 cores of Ruby work; with headroom, plan for at least 14 workers. Four servers with 4 vCPUs each give 16.
- Threads: since 58% of the request is I/O, 3 threads per worker let a process overlap waits and keep its core busy. More threads would mostly add GVL wait.
- Connections: 16 workers × 3 threads = 48 web connections, plus Sidekiq. Well under 100.
The load test then checks the assumptions. If p95 bends at 120 RPS with CPU at 60%, the 70 ms of I/O is growing under load and the database deserves the next look.
Pre-test checklist
RAILS_ENV=production, eager loading on, log level:infoor higher, assets precompiled and served by a CDN or proxy.WEB_CONCURRENCYset from CPU allocation;RAILS_MAX_THREADSchosen on purpose;preload_app!on.- Database pool ≥ threads in every process type (Puma, Sidekiq, Solid Queue); cluster total under
max_connections. - YJIT confirmed enabled; memory limit leaves room for its compiled code.
- Puma stats exposed or logged so backlog and pool capacity are visible.
- Script handles the session cookie and authenticity token; Rack::Attack limits adjusted for the test.
- Test data varied enough to defeat a single hot cache key.
- Sidekiq queue latency monitored, and the drain time after the test recorded.
A practical sequence in LoadTester: run an exact-RPS ramp with a p95 threshold to find the knee, change one Puma setting (threads 3 to 5, or one more worker), and run again. Run comparison shows whether the change moved the ceiling or only moved the latency.
FAQ
How many Puma threads should a Rails app use?
Rails generates config/puma.rb with 3 threads per process, and the Rails tuning guide says an app that spends no more than about half its time on I/O is unlikely to benefit from more. Apps that call slow external APIs inline may gain from more threads. Load test 3 against 5 at the same RPS and compare p95.
Why do I get ActiveRecord::ConnectionTimeoutError under load?
A thread waited longer than checkout_timeout (5 seconds by default) for a database connection, because every connection in the pool was in use. Usually the pool is smaller than the number of threads that use it, for example Sidekiq concurrency above the pool size, or connections are held during slow work. Make the pool at least as large as the thread count in each process.
Why do my POST requests return 422 during a Rails load test?
That is usually ActionController::InvalidAuthenticityToken. Your script is posting without the CSRF token that belongs to its session. GET the form first, keep the session cookie, extract authenticity_token from the form or the csrf-token meta tag, and send it back as a form field or X-CSRF-Token header.
Should YJIT be on for production and load tests?
Yes, in most cases. Rails 7.2 and later enable YJIT by default when running on Ruby 3.3 or newer, and the Rails guide recommends it unless memory is tight. It uses extra memory, so check per-process RSS during the test, and make sure the load test runs long enough for YJIT to compile hot code.
Should I include Sidekiq in a Rails load test?
Yes, if the requests you test enqueue jobs. Measure enqueue rate against processing rate per queue, and give Sidekiq's database pool at least as many connections as its concurrency setting. A web tier that looks healthy while queue latency climbs for an hour is still a failed test.