Framework guide

How to Load Test a Rails App

Updated October 27, 2026•11 min read•Rails•Ruby

Written by

Reviewed by the LoadTester editorial team. Review process: see the editorial policy.

Published
2026-10-27
Last reviewed
2026-10-27
Author
Kristian Razum
Diagram of Puma worker processes, each with its own GVL, three threads and a matching Active Record connection pool

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.rb reads WEB_CONCURRENCY, defaults to 1, and accepts auto to 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.
Puma workers each hold a GVL, three threads, and a pool of at least three database connections Puma worker 1 GVL: one thread runs Ruby thread thread thread AR pool max_connections ≥ 3 Puma worker 2 GVL: one thread runs Ruby thread thread thread AR pool max_connections ≥ 3 … PostgreSQL workers × threads + Sidekiq concurrency Example: 4 servers × 4 workers × 3 threads = 48 web connections, + 2 Sidekiq processes × 10 = 68.
Each Puma worker is a separate process with its own GVL and its own Active Record pool. Total database connections grow with servers × workers × threads, plus background job processes.

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

SettingDefaultWhat to changeWhy
WEB_CONCURRENCY (Puma workers)1 in generated puma.rbAbout 1 per CPU coreThe GVL limits each process to one core of Ruby
RAILS_MAX_THREADS3Keep 3; test 1 or 5 for latency vs throughputMore threads overlap I/O but add GVL contention
max_connections / poolRAILS_MAX_THREADS or 5≥ threads in that process (web and jobs)Prevents ConnectionTimeoutError
checkout_timeout5 s1 to 2 sOverload shows up quickly and clearly
YJITOn (Rails 7.2+, Ruby 3.3+)Keep on; tune --yjit-exec-mem-size if memory is tightLess CPU per request
preload_app!Off unless configuredOnCopy-on-write memory sharing between workers
Sidekiq concurrency5From job I/O profile; match its poolJob threads need connections too
PostgreSQL max_connections100≥ total of all pools + margin, or PgBouncerScaling 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:

  1. GET the form page and keep the session cookie (_yourapp_session).
  2. Extract authenticity_token from the hidden form field, or csrf-token from the <meta> tag.
  3. Send it back as the authenticity_token form parameter or the X-CSRF-Token header.

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

SignalWhereMeaning
backlog above 0, pool_capacity at 0Puma.stats or pumactl statsAll threads busy; requests queuing before Rails
Client p95 far above Rails' logged durationLoad test vs logs or APMQueue time in Puma or the proxy
Request time mostly "ActiveRecord"Rails log line or APMDatabase-bound: slow queries, N+1, locks
CPU at 100% per worker, low DB timetop, APMRuby-bound: views, serialization; add workers or cores
Sidekiq queue latency risingSidekiq Web UIJob throughput below enqueue rate
Worker RSS growing steadilyContainer metricsFragmentation 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

  1. RAILS_ENV=production, eager loading on, log level :info or higher, assets precompiled and served by a CDN or proxy.
  2. WEB_CONCURRENCY set from CPU allocation; RAILS_MAX_THREADS chosen on purpose; preload_app! on.
  3. Database pool ≥ threads in every process type (Puma, Sidekiq, Solid Queue); cluster total under max_connections.
  4. YJIT confirmed enabled; memory limit leaves room for its compiled code.
  5. Puma stats exposed or logged so backlog and pool capacity are visible.
  6. Script handles the session cookie and authenticity token; Rack::Attack limits adjusted for the test.
  7. Test data varied enough to defeat a single hot cache key.
  8. 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.

Run this as a real load testFree plan: 10 VUs, 50 RPS, live p95/p99 and error charts, no credit card. Starter $19/mo (250 VUs) or Pro $49/mo (2,000 VUs) with a 7-day free trial when you need more.
Start freeCompare plans