The Rails Connection Pool Is a Lie: Sizing for Puma + Solid Queue + Multiple Databases in Rails 8
You configured pool: 5 in database.yml. Rails opened 108 connections. Allow me to show the arithmetic.
Good evening. Your database.yml is not telling you the whole story.
Rails 8 shipped with what DHH calls the Solid Trifecta: Solid Queue for background jobs, Solid Cache for caching, and Solid Cable for WebSocket pub/sub. All backed by databases. All using ActiveRecord. All with their own connection pools.
This is, on its merits, an excellent architectural decision. Fewer moving parts. Transactional guarantees where you previously had fire-and-forget semantics. One fewer infrastructure dependency to monitor, patch, and restart at inconvenient hours. I approve of the direction.
What I do not approve of is the connection math nobody mentioned.
When you run rails new with Rails 8 defaults, you get a database.yml with four database entries: primary, queue, cache, and cable. Each one declares pool: 5. Five connections sounds modest. Conservative, even. The sort of number that inspires no alarm whatsoever.
It is, I'm afraid, a lie. Not intentionally — the Rails team did not set out to deceive. The pool size is per database, per process. And you have more processes than you think. Considerably more.
A standard production deployment with 2 Puma workers, 5 threads, and Solid Queue running 2 processes with 5 threads will open 108 PostgreSQL connections before your application serves its first request. Scale to 4 Puma workers across 2 servers with a dedicated Solid Queue box, and you are looking at 284 connections. PostgreSQL's default max_connections is 100.
The math is not complicated. But nobody does it. Allow me to do it for you, step by step, with the thoroughness this matter deserves.
Where do all these databases come from?
Before Rails 8, a typical Rails application had one database connection in database.yml. Background jobs went through Redis (Sidekiq, Resque). Caching went through Redis or Memcached. WebSocket pub/sub went through Redis. One connection pool. One pool size to think about. One number to get right.
The Solid Trifecta replaces Redis with PostgreSQL for all three services. This means your Rails application now maintains four separate ActiveRecord connection pools, each with its own checkout/checkin lifecycle, its own idle timeout, and its own pool size. Four pools, four checkout timeouts, four idle timeouts, four opportunities for misconfiguration.
# config/database.yml — Rails 8 with the Solid Trifecta
#
# What you think you have: 1 database.
# What you actually have: 4 databases, each with its own connection pool.
default: &default
adapter: postgresql
encoding: unicode
pool: <%= ENV.fetch("RAILS_MAX_THREADS", 5) %>
production:
primary:
<<: *default
url: <%= ENV["DATABASE_URL"] %>
pool: <%= ENV.fetch("RAILS_MAX_THREADS", 5) %>
queue:
<<: *default
url: <%= ENV["QUEUE_DATABASE_URL"] %>
pool: <%= ENV.fetch("RAILS_MAX_THREADS", 5) %>
migrations_paths: db/queue_migrate
cache:
<<: *default
url: <%= ENV["CACHE_DATABASE_URL"] %>
pool: <%= ENV.fetch("RAILS_MAX_THREADS", 5) %>
migrations_paths: db/cache_migrate
cable:
<<: *default
url: <%= ENV["CABLE_DATABASE_URL"] %>
pool: <%= ENV.fetch("RAILS_MAX_THREADS", 5) %>
migrations_paths: db/cable_migrate Notice the pool size. Each database inherits pool: 5 from the default anchor. That 5 is per database, per Puma worker process. A single Puma worker with 5 threads and 4 databases opens 20 connections. Two workers: 40. Four workers across two servers: 160. And those are only the Puma connections.
I should note that the YAML anchor pattern here — &default and <<: *default — is particularly insidious. It looks like sensible DRY configuration. What it actually does is propagate the same pool size to databases with fundamentally different access patterns. Your primary database, which handles every application query, gets the same pool as your cache database, which handles sub-millisecond key lookups. This is the infrastructural equivalent of giving every room in the household the same thermostat setting because it is "simpler."
Puma's multiplication effect
Puma runs a cluster of worker processes, each forked from the master. Each worker is a separate OS process with its own memory space, its own Ruby VM, and — critically — its own set of ActiveRecord connection pools. Sharing does not occur across the fork boundary. It cannot. These are independent processes.
# config/puma.rb — a typical production configuration
#
# Workers = separate OS processes (forked).
# Threads = concurrent request handlers within each worker.
# Each thread needs its own connection from each pool.
workers ENV.fetch("WEB_CONCURRENCY", 2)
max_threads_count = ENV.fetch("RAILS_MAX_THREADS", 5)
min_threads_count = ENV.fetch("RAILS_MIN_THREADS", max_threads_count)
threads min_threads_count, max_threads_count
preload_app!
on_worker_boot do
ActiveRecord::Base.establish_connection
end When the on_worker_boot block calls ActiveRecord::Base.establish_connection, it reconnects all four database pools for that worker. Each worker opens its own connections. The threads within that worker share the worker's pool, but workers do not share connections with each other.
This means the real Puma connection count is:
Puma connections per database = workers x threads
With 2 workers and 5 threads: 10 connections to each of the four databases. That is 40 connections from Puma alone.
With 4 workers and 5 threads on two servers: 80 connections per database. 320 connections across all four. From your web tier alone, before a single background job runs.
I want to be precise about a subtlety here. The connections are not all opened at once. ActiveRecord uses lazy connection creation — connections are established on first use and added to the pool up to the configured maximum. In a freshly booted Puma worker with pool: 5, you start with zero connections and grow to five as threads make their first queries. But under sustained load, every pool reaches its maximum within seconds. The lazy creation buys you a few moments of grace, not a lasting reduction.
There is also the matter of preload_app! in the Puma configuration. When enabled, the master process loads the Rails application before forking workers. This is desirable for memory efficiency (copy-on-write sharing of loaded code) but it means the master process establishes database connections that must be discarded after forking. The on_worker_boot callback reconnects, but if you forget it — and I have seen this in production more often than I would like to admit — you get workers sharing the master's connections across process boundaries. The symptoms are bewildering: intermittent query errors, data corruption, connections mysteriously closing mid-transaction.
Solid Queue's hidden connection appetite
Solid Queue is the one that catches people off guard. It looks like a simple config — a few lines of YAML. How much damage can a few lines of YAML do? Allow me to itemize.
# config/solid_queue.yml
#
# Each dispatcher polls the queue database.
# Each worker thread holds a connection from the queue pool.
# The supervisor process holds its own connections too.
production:
dispatchers:
- polling_interval: 0.1
batch_size: 500
workers:
- queues: "*"
threads: 5
processes: 2
polling_interval: 0.1 # Solid Queue's connection usage is particularly tricky.
#
# The supervisor process itself holds connections:
# - 1 connection to the queue database (heartbeat)
# - 1 connection to the primary database (if using Active Job callbacks)
#
# Each dispatcher holds:
# - 1 connection to the queue database (polling)
#
# Each worker thread holds:
# - 1 connection to the queue database (claim + complete)
# - 1 connection to the primary database (your job's queries)
# - 1 connection to the cache database (if jobs use Rails.cache)
#
# For a Solid Queue config with 2 processes x 5 threads:
#
# Queue DB: 2 supervisors + 2 dispatchers + 10 workers = 14
# Primary DB: 2 supervisors + 10 workers = 12
# Cache DB: 10 workers (if jobs use cache) = 10
#
# These are ON TOP of the Puma connections.
# And they are easy to miss because Solid Queue runs
# in a separate process that you did not configure database.yml for. The surprise is not that Solid Queue needs connections to the queue database — that is obvious. The surprise is that it also needs connections to the primary database (for your job code that runs ActiveRecord queries), the cache database (if your jobs touch Rails.cache), and potentially the cable database (if jobs broadcast to Action Cable channels).
Each Solid Queue worker thread is a concurrent consumer that may hold connections across multiple pools simultaneously. The supervisor process and dispatcher threads add their own. For a deployment with 2 Solid Queue processes and 5 threads each, you are looking at 14 connections to the queue database, 12 to primary, and potentially 10 to cache. From background jobs alone.
This is documented, technically. Solid Queue's README mentions connection handling. But it does not tell you the total number. You have to multiply it out yourself, account for the cross-database access patterns, and add it to the Puma numbers. Nobody does this at setup time. Everyone does it at 3 AM when connections start failing.
There is a further subtlety that warrants attention. Solid Queue's polling mechanism — the dispatcher checking for ready jobs — holds a connection to the queue database continuously. Unlike Puma threads, which check out connections per-request and return them, the dispatcher's connection is effectively permanent. It polls every 100ms by default. That connection never returns to the pool. It is, for all practical purposes, dedicated infrastructure. Factor it into your numbers as a fixed cost, not a shared resource.
The connection checkout lifecycle: what actually happens
To understand why pool sizing matters beyond the raw numbers, you need to understand what happens when a thread needs a database connection. The lifecycle is more involved than most developers expect.
# ActiveRecord connection checkout lifecycle
# (what actually happens when a thread needs a database connection)
# 1. Thread asks the pool for a connection
conn = ActiveRecord::Base.connection_pool.checkout
# 2. If a connection is available in the pool, return it immediately
# Time: ~0.01ms
# 3. If the pool is at capacity but a connection is idle, reap it
# Time: ~0.1ms (involves mutex acquisition)
# 4. If all connections are in use, WAIT for one to be returned
# Time: 0ms to 5000ms (checkout_timeout, default 5 seconds)
# 5. If checkout_timeout expires, raise:
# ActiveRecord::ConnectionTimeoutError:
# "could not obtain a database connection within 5.000 seconds
# (waited 5.000 seconds); all pooled connections were in use"
# The pool is PER DATABASE PER PROCESS.
# A Puma worker with 5 threads and 4 databases has 4 pools.
# If the primary pool has 5 connections and all 5 threads
# are in the middle of a primary query, thread 6 (from a
# concurrent request) waits up to 5 seconds, then errors.
#
# Now multiply by 4 databases. The thread might get a primary
# connection but block waiting for a queue connection to enqueue
# a background job. The bottleneck is whichever pool fills first. The critical insight is that connection checkout is a blocking operation with a timeout. When a thread cannot obtain a connection, it does not fail fast — it waits. For up to 5 seconds by default. During those 5 seconds, the request is consuming a Puma thread, a socket, and memory, while producing no useful work. It is, if you will forgive the observation, the server equivalent of standing in a queue at the post office while every window is occupied by someone filling out paperwork.
With four connection pools per worker, a single request may need to check out connections from multiple pools. A request that enqueues a background job and writes to the cache needs connections to primary, queue, and cache simultaneously. If any one of those pools is at capacity, the entire request blocks. The bottleneck is always the most constrained pool, and the default configuration makes every pool equally constrained — which means the busiest pool becomes the chokepoint for all operations.
The formula
Here it is. The math that should be on the first page of every Rails 8 deployment guide but, for reasons I cannot fathom, is not.
# The Formula: Total PostgreSQL Connections
#
# For EACH database (primary, queue, cache, cable):
#
# puma_connections = puma_workers x puma_threads
# queue_connections = solid_queue_processes x solid_queue_threads
# cable_connections = action_cable_worker_pool_size (default: 4)
# other_connections = console sessions + migrations + cron + health checks
#
# Total per database = puma + queue + cable + other
# Total across all databases = sum of all four
#
# With the default Rails 8 scaffold values:
#
# Puma: 2 workers x 5 threads = 10 connections per DB
# Solid Queue: 2 processes x 5 threads = 10 connections per DB
# Action Cable: 4 worker threads = 4 connections per DB
# Overhead: console + cron + misc = 3 connections per DB
# ─────────────────────
# Per database: 27 connections
# x 4 databases: 108 connections total
#
# You have not written a single line of application code yet. 108 connections with the default scaffold values. The most conservative, out-of-the-box Rails 8 configuration. You have not tuned anything. You have not scaled anything. You ran rails new, deployed to a single server, and you are already past PostgreSQL's default limit if you count all four databases pointing at the same PostgreSQL instance.
Of course, if the four databases are actually four schemas within the same PostgreSQL instance — which is the common setup, and the one every Rails 8 tutorial demonstrates — they all share a single max_connections pool. 108 connections against a limit of 100. The 101st connection request gets a FATAL: too many connections for role error. Your application starts returning 500s on seemingly random requests. Not every request — just the unlucky ones that arrive when the pool is at capacity. Intermittent failures are, in my experience, considerably worse than consistent ones. At least a consistently broken application announces itself clearly.
A worked example: mid-size SaaS
Default scaffold values are useful for illustration. Real deployments are worse. Here is a mid-size SaaS application — nothing exotic, nothing hyperscale. Two app servers, one background processing server, Action Cable for live features. The sort of infrastructure that fits comfortably in a single AWS region on modest instances.
# Real deployment: a mid-size Rails 8 SaaS application
#
# Infrastructure:
# 2x app servers, each running Puma
# 1x background server running Solid Queue
# Action Cable via Solid Cable
#
# Puma config (per app server):
# workers: 4
# threads: 5
#
# Solid Queue config (background server):
# processes: 3
# threads: 10
#
# ──────────────────────────────────────────────────────
# PRIMARY database connections:
# App server 1: 4 workers x 5 threads = 20
# App server 2: 4 workers x 5 threads = 20
# Solid Queue: 3 processes x 10 threads = 30
# Action Cable: 2 servers x 4 threads = 8
# Overhead: consoles, cron, deploys = 5
# ────
# PRIMARY total: 83
#
# QUEUE database connections:
# App server 1: 4 x 5 = 20 (enqueueing jobs)
# App server 2: 4 x 5 = 20
# Solid Queue: 3 x 10 = 30 (dequeueing jobs)
# Overhead: 5
# ────
# QUEUE total: 75
#
# CACHE database connections:
# App server 1: 4 x 5 = 20
# App server 2: 4 x 5 = 20
# Solid Queue: 3 x 10 = 30 (jobs reading cache)
# Overhead: 5
# ────
# CACHE total: 75
#
# CABLE database connections:
# App server 1: 4 x 5 = 20
# App server 2: 4 x 5 = 20
# Action Cable: 2 x 4 = 8
# Overhead: 3
# ────
# CABLE total: 51
#
# ══════════════════════════════════════════════════════
# GRAND TOTAL: 83 + 75 + 75 + 51 = 284 connections
# ══════════════════════════════════════════════════════
#
# PostgreSQL default max_connections: 100.
# You are 184 connections over the limit. 284 connections. The PostgreSQL default allows 100. Even if you raise max_connections to 300, you are operating at 95% capacity with zero headroom for maintenance connections, migrations, console sessions, or traffic spikes.
The table below shows how connection counts scale across different deployment sizes:
| Deployment | Puma | Solid Queue | Cable | Overhead | Per DB | Total (4 DBs) |
|---|---|---|---|---|---|---|
| Solo server (minimal) | 2w x 3t | 1p x 3t | 4 | 3 | 16 | 64 |
| Small SaaS (1 app + 1 bg) | 2w x 5t | 2p x 5t | 4 | 5 | 29 | 116 |
| Mid-size (2 app + 1 bg) | 4w x 5t (x2) | 3p x 10t | 8 | 5 | 83 | 284* |
| Larger (4 app + 2 bg) | 4w x 5t (x4) | 4p x 10t (x2) | 16 | 10 | 186 | 744* |
* Not all databases need connections from all processes. The "Per DB" column shows the primary database (worst case). Real totals vary by which processes access which databases. The "Total" column accounts for this — queue and cache databases typically need fewer connections than primary.
The pattern is clear. Every Puma worker you add multiplies by 4 (one pool per database). Every Solid Queue process you add multiplies by 2-4 depending on which databases your jobs access. Horizontal scaling is a connection multiplier, and the multiplier is the number of databases.
I have seen teams add a third app server to handle a traffic spike and trigger a cascading connection failure within minutes. The server that was meant to relieve load became the cause of the outage. The irony is not lost on anyone involved, though it tends to be appreciated more in retrospect than in the moment.
The symptoms when you get it wrong
Connection exhaustion does not announce itself cleanly. It does not send a polite email. It manifests as a collection of seemingly unrelated symptoms, each of which, taken alone, might lead you down the wrong diagnostic path.
- Intermittent 500 errors. Not on every request — only when all pool connections are checked out and a new request needs one. Under moderate load, this might be 1 in 50 requests. Under heavy load, most of them. The intermittency is what makes this maddening. Your test suite passes. Your staging environment works. Production fails sporadically.
- ActiveRecord::ConnectionTimeoutError. The application-side error when the pool checkout timeout expires. Default is 5 seconds. Your p99 latency spikes to 5000ms and then returns an error anyway. Users experience this as the application freezing and then showing an error page. A double insult.
- FATAL: too many connections for role. The PostgreSQL-side error when
max_connectionsis exhausted. No new connections can be established until existing ones close. Every process that tries to establish a connection fails simultaneously. This is the nuclear variant — not just slow, but completely unable to connect. - Solid Queue jobs silently stalling. Background jobs that cannot acquire a connection sit in the claimed state indefinitely. No error in your application logs — just jobs that never complete. The Solid Queue dashboard shows them as "in progress" for hours. Your users wonder why their export never arrived. Your team wonders why the job did not fail. It did not fail because it never ran — it could not obtain the resources to begin.
- CPU spikes with low query throughput. PostgreSQL's performance degrades with too many connections even if they are idle. OS scheduling overhead, lock manager contention, shared buffer management — all scale with connection count, not query count. You see PostgreSQL at 80% CPU with only 10 queries per second. The CPU is not executing queries. It is managing connections.
GitHub issue #508 on the Solid Queue repository shows real users encountering exactly this. CPU spikes. Connection exhaustion. The fix is not in Solid Queue — it is in understanding the total connection math.
The invisible consumers: health checks and deploys
The formula accounts for Puma, Solid Queue, and Action Cable. But there are connection consumers that do not appear in any configuration file. They arrive uninvited and consume resources without introduction. Quite rude, if I may say.
# Health checks: the invisible connection consumer
#
# Most Rails deployments have health check endpoints:
# config/routes.rb
get "/health", to: proc {
# This checks database connectivity:
ActiveRecord::Base.connection.execute("SELECT 1")
[200, {}, ["ok"]]
}
# Kubernetes, ELB, or your load balancer hits this every 5-30 seconds.
#
# The problem: each health check request checks out a connection
# from the primary pool, executes SELECT 1, and returns it.
#
# With 2 app servers, each receiving health checks every 10 seconds:
# - 12 health checks per minute per server
# - Each holds a primary pool connection for ~2ms
# - Seems harmless
#
# But if your pool is at capacity during a traffic spike,
# health checks compete with real requests for connections.
# The health check times out. The load balancer marks the
# server as unhealthy. Removes it from rotation. Now the
# remaining server gets ALL traffic. Its pool fills faster.
# Its health checks fail. Cascading failure.
#
# The fix: a health check that does NOT use the connection pool.
get "/health", to: proc {
# Check if the process is alive, not if the pool has capacity:
[200, {}, ["ok"]]
}
# Or use a dedicated connection outside the pool:
get "/health/deep", to: proc {
ActiveRecord::Base.connection_pool.with_connection do |conn|
conn.execute("SELECT 1")
end
[200, {}, ["ok"]]
} Health checks are the polite guest who becomes a problem during a dinner party. Under normal conditions, they are harmless — a brief connection checkout, a SELECT 1, a quick return. But when connection pools are under pressure, health checks compete with real requests. And when health checks fail, the load balancer takes action. Catastrophic action, frequently.
Deploys are the other invisible consumer, and they are considerably less polite.
# The deployment connection spike
#
# During a rolling deploy, you briefly have DOUBLE the connections:
#
# Timeline:
# t=0: Old Puma (2 workers x 5 threads) = 40 connections across 4 DBs
# t=1: New Puma starts, old Puma still running
# Old: 40 connections + New: 40 connections = 80 connections
# t=2: Old Puma receives SIGTERM, starts graceful shutdown
# Old: 40 connections (draining) + New: 40 connections = 80
# t=3: Old Puma closes connections as requests complete
# Old: ~20 connections + New: 40 connections = 60
# t=4: Old Puma fully stopped
# New: 40 connections
#
# For 30-60 seconds, you have roughly double the connections.
# If you are at 80% of max_connections during normal operation,
# the deploy pushes you past 100%.
#
# Solid Queue has the same problem:
# Old SQ processes: 28 connections + New SQ processes: 28 connections
#
# Total spike during deploy:
# Normal: 108 connections
# During deploy: ~190 connections
# PostgreSQL max_connections: 100
#
# This is why deploys fail on Friday afternoon
# when nobody changed any code. During a rolling deployment, old and new processes coexist for 30-60 seconds. If you are running at 80% of max_connections under normal operation, the deployment spike pushes you past 100%. This is why teams report that "nothing changed but the deploy broke the database." Something did change — the connection count doubled for a minute.
I have attended to numerous incidents where the team spent hours searching application logs for the bug that caused a deployment failure, only to discover that the application code was correct. The database was correct. The deployment itself was the problem, because it transiently doubled the connection demand.
"If idle connections dominate — dozens or hundreds of connections sitting open with no work — you have a pooling problem. Connections are being held by application instances that are not using them."
— from You Don't Need Redis, Chapter 18: The PostgreSQL Performance Decision Framework
Diagnosing your actual connection count
Before you change anything, measure. I cannot overstate this. Assumptions about connection usage are reliably wrong, and the remedy for wrong assumptions is not better assumptions — it is data.
-- How many connections do you actually have right now?
SELECT datname,
usename,
application_name,
state,
count(*) as connections
FROM pg_stat_activity
GROUP BY datname, usename, application_name, state
ORDER BY connections DESC;
-- Example output on a Rails 8 app with Solid Trifecta:
--
-- datname | usename | application_name | state | connections
-- ──────────────┼─────────┼──────────────────┼────────┼────────────
-- myapp_prod | deploy | puma | idle | 35
-- myapp_queue | deploy | solid_queue | idle | 28
-- myapp_queue | deploy | puma | idle | 18
-- myapp_cache | deploy | puma | idle | 18
-- myapp_cable | deploy | puma | idle | 15
-- myapp_prod | deploy | solid_queue | active | 8
-- myapp_cable | deploy | action_cable | idle | 6
-- myapp_prod | deploy | puma | active | 4
--
-- Total: 132 connections. PostgreSQL max_connections: 100.
-- Something is about to break. Run this against your production PostgreSQL. Group by application_name — Puma, Solid Queue, and Action Cable each set this automatically. If the total is anywhere near max_connections, you are one deployment or traffic spike away from an outage.
-- Detecting connection leaks: connections that have been idle too long
-- These are connections checked out by the application but not actively used
SELECT pid,
datname,
usename,
application_name,
state,
state_change,
now() - state_change as idle_duration,
query
FROM pg_stat_activity
WHERE state = 'idle'
AND now() - state_change > interval '5 minutes'
ORDER BY idle_duration DESC;
-- Connections idle for more than 5 minutes in a pool-per-thread model
-- suggest either:
-- 1. Pool size is larger than needed (connections opened but unused)
-- 2. A connection leak (checked out but never returned)
-- 3. idle_timeout in database.yml is too high
--
-- In a healthy Rails app with pool: 5 and 5 threads,
-- you should see connections cycling between 'active' and 'idle'
-- with idle durations measured in milliseconds, not minutes. Connections that have been idle for minutes in a pool-per-thread model suggest oversized pools. In a healthy Rails application where the pool matches the thread count, connections should cycle between active and idle rapidly. Long idle durations mean you are holding PostgreSQL resources you are not using — resources that other processes cannot access because they count against max_connections regardless of their state.
-- A comprehensive connection audit for Rails 8 + Solid Trifecta
-- Run this weekly (or after any scaling change)
WITH connection_summary AS (
SELECT
datname,
application_name,
state,
count(*) as conn_count,
max(now() - state_change) as max_idle
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY datname, application_name, state
)
SELECT
datname as database,
application_name as app,
state,
conn_count,
max_idle,
round(100.0 * conn_count / current_setting('max_connections')::int, 1)
as pct_of_max
FROM connection_summary
ORDER BY conn_count DESC;
-- Also check: how close are you to the limit?
SELECT
current_setting('max_connections')::int as max_connections,
count(*) as current_connections,
current_setting('max_connections')::int - count(*) as remaining,
round(100.0 * count(*) / current_setting('max_connections')::int, 1)
as utilization_pct
FROM pg_stat_activity; This monitoring query gives you the full picture: which applications are using connections, what state those connections are in, and what percentage of your max_connections budget each consumer represents. Run it weekly. Run it after every scaling change. Run it before every deployment to a new server configuration. The numbers do not lie, even when the YAML does.
-- Checking and adjusting max_connections
SHOW max_connections; -- Default: 100
-- To change it (requires restart):
-- In postgresql.conf:
-- max_connections = 300
--
-- But wait. More connections is not the answer.
-- Each connection costs 5-10MB of RAM.
-- 300 connections = 1.5-3GB just for connection overhead.
-- 500 connections = context switching dominates CPU time.
--
-- The PostgreSQL documentation is blunt about this:
-- "Increasing max_connections costs about 400 bytes of
-- shared memory per connection slot, plus lock space."
--
-- The real cost is not the memory. It is the contention.
-- More connections competing for the same locks, the same
-- CPU cores, the same I/O bandwidth. Throughput peaks
-- around (CPU cores x 2) and degrades from there. Raising max_connections is the first thing everyone tries. It is also the wrong answer. More connections means more RAM, more scheduling overhead, and more contention. PostgreSQL's throughput peaks around (CPU cores x 2) + disk spindles active connections. On an 8-core server, that is roughly 17 active connections delivering maximum throughput. Everything above that is queueing, not parallelism.
I should be honest about the nuance here: raising max_connections is not always wrong. If your server has 64GB of RAM and you need 300 connections, the memory overhead (1.5-3GB) is manageable. The problem is that it treats the symptom — "not enough connection slots" — without addressing the cause — "too many processes opening too many pools." You can raise the limit and function correctly for months, until you add another app server and exceed the new limit. The fundamental arithmetic has not changed. You have merely bought time.
A connection pooler is the standard solution. But before you add one, you should right-size your pools. It is cheaper, faster, and teaches you things about your application that a pooler hides.
Right-sizing each pool independently
The default Rails 8 scaffold sets every pool to RAILS_MAX_THREADS. This is wrong for three out of four databases. Each database has a different access pattern and needs a different pool size.
# config/database.yml — after doing the math
#
# The fix is not raising max_connections.
# The fix is right-sizing each pool.
production:
primary:
<<: *default
url: <%= ENV["DATABASE_URL"] %>
pool: <%= ENV.fetch("PRIMARY_POOL_SIZE", 10) %>
queue:
<<: *default
url: <%= ENV["QUEUE_DATABASE_URL"] %>
pool: <%= ENV.fetch("QUEUE_POOL_SIZE", 5) %>
migrations_paths: db/queue_migrate
cache:
<<: *default
url: <%= ENV["CACHE_DATABASE_URL"] %>
pool: <%= ENV.fetch("CACHE_POOL_SIZE", 3) %>
migrations_paths: db/cache_migrate
cable:
<<: *default
url: <%= ENV["CABLE_DATABASE_URL"] %>
pool: <%= ENV.fetch("CABLE_POOL_SIZE", 3) %>
migrations_paths: db/cable_migrate
# Why different sizes?
#
# PRIMARY: your application's main queries. Needs the most headroom.
# QUEUE: Solid Queue workers need connections, but web threads
# only need them briefly when enqueueing.
# CACHE: reads are fast, connections are released quickly.
# A smaller pool is usually sufficient.
# CABLE: Action Cable holds connections longer (pub/sub),
# but fewer concurrent operations.
#
# The key insight: not every database needs the same pool size.
# The default Rails scaffold sets them all to RAILS_MAX_THREADS.
# That is wrong for three of the four. The logic:
- Primary: This is your application's workhorse. Every Puma thread may need a connection at any time. Pool size should match or slightly exceed your thread count. This is the one database where the default
RAILS_MAX_THREADSsizing is approximately correct. - Queue: Puma threads only need a queue connection when they enqueue a job — a fast operation that completes in under a millisecond. The pool can be smaller than threads because not every thread enqueues on every request. Solid Queue workers need connections too, but they run in separate processes with their own pools.
- Cache: Cache reads and writes are fast. Connections are checked out and returned quickly. A smaller pool handles the same throughput because connections are never held for long. If your average cache operation is 0.5ms and your average request is 50ms, a cache pool of 3 can serve the same throughput as a primary pool of 10.
- Cable: Action Cable pub/sub operations are fast. Unless you have heavy WebSocket traffic with thousands of concurrent subscribers, this pool can be conservative.
But there is a deeper optimization available. Puma workers and Solid Queue processes have opposite access patterns, yet they read the same database.yml. With environment variables, you can give them different pool sizes at runtime.
# Configuring separate pool sizes for Solid Queue processes
#
# The trick: Solid Queue processes read the same database.yml
# but you can use environment variables to give them
# different pool sizes than Puma workers.
# config/database.yml
production:
primary:
<<: *default
url: <%= ENV["DATABASE_URL"] %>
pool: <%= ENV.fetch("PRIMARY_POOL_SIZE", 5) %>
queue:
<<: *default
url: <%= ENV["QUEUE_DATABASE_URL"] %>
pool: <%= ENV.fetch("QUEUE_POOL_SIZE", 5) %>
# For Puma (in your Procfile or systemd unit):
# PRIMARY_POOL_SIZE=10 QUEUE_POOL_SIZE=3 bundle exec puma
#
# For Solid Queue:
# PRIMARY_POOL_SIZE=5 QUEUE_POOL_SIZE=15 bundle exec solid_queue:start
#
# Puma needs a large primary pool (every request queries primary)
# but a small queue pool (only enqueue operations, fast checkout).
#
# Solid Queue needs the opposite: a large queue pool (every worker
# thread polls and claims jobs) but a moderate primary pool
# (only job execution code, not every thread runs primary queries).
#
# Same database.yml. Different runtime pool sizes.
# This alone can cut total connections by 25-35%. This technique — same database.yml, different runtime pool sizes via environment variables — can reduce your total connection count by 25-35% without any application code changes. Puma gets a large primary pool and a small queue pool. Solid Queue gets the opposite. Each process is sized for its actual access pattern rather than the generic default.
Pool reaping and idle timeouts: the parameters everyone ignores
ActiveRecord's connection pool has three configuration parameters beyond pool that directly affect connection lifecycle. They are rarely discussed, universally left at defaults, and more consequential than their obscurity suggests.
# ActiveRecord connection pool reaping
#
# Reaping closes connections that have been checked out but
# not returned — typically from dead threads or leaked connections.
# config/database.yml
production:
primary:
<<: *default
url: <%= ENV["DATABASE_URL"] %>
pool: 10
checkout_timeout: 5 # seconds to wait for a connection (default: 5)
reaping_frequency: 10 # seconds between reap cycles (default: 60)
idle_timeout: 300 # seconds before idle connections are closed (default: 300)
# checkout_timeout: How long a thread waits for a pool connection.
# Too low: false timeouts under brief load spikes.
# Too high: requests hang for seconds before failing.
# 5 seconds is the right default for most applications.
#
# reaping_frequency: How often the pool checks for dead connections.
# The default of 60 seconds means a leaked connection occupies
# a pool slot for up to a minute. Reducing to 10 seconds is
# aggressive but harmless — the reaper is cheap.
#
# idle_timeout: How long an unused connection stays open.
# 300 seconds (5 minutes) is sensible. Lower values cause
# connection churn during traffic lulls. Higher values hold
# PostgreSQL connections open unnecessarily. The reaping_frequency parameter is particularly important for Solid Queue deployments. If a Solid Queue worker thread dies mid-job — an OOM kill, an unrescued exception that crashes the thread — the connection it held remains checked out in the pool. The reaper must reclaim it. At the default frequency of 60 seconds, a dead thread's connection occupies a pool slot for up to a minute. In a pool of 5, that is 20% of your capacity unavailable. Reducing reaping_frequency to 10 seconds is aggressive but harmless — the reaper's overhead is negligible.
The idle_timeout is a double-edged sword. Lower values (say, 60 seconds) aggressively close unused connections, reducing your PostgreSQL connection footprint during quiet periods. But they also cause connection churn when traffic resumes — new connections must be established, which costs 2-5ms each on a local PostgreSQL instance and 10-50ms on a remote one. For most Rails applications, the default of 300 seconds is a reasonable balance.
An honest word about PgBouncer
I would be remiss — and a poor waiter indeed — if I discussed connection pooling without addressing PgBouncer directly. It is the most widely deployed PostgreSQL connection pooler, it is mature and reliable, and it is the recommendation you will find in most guides. Allow me to be honest about both its strengths and its limitations in a Rails 8 context.
# PgBouncer: the traditional connection pooler for PostgreSQL
#
# PgBouncer sits between your application and PostgreSQL,
# multiplexing many application connections to fewer database connections.
#
# Three pooling modes:
#
# session: 1:1 mapping until client disconnects
# (basically no pooling — just a proxy)
#
# transaction: connection assigned per transaction, returned on COMMIT
# (the useful mode for Rails applications)
#
# statement: connection assigned per query
# (breaks multi-statement transactions — unusable for Rails)
#
# PgBouncer in transaction mode with Rails + Solid Trifecta:
#
# Pros:
# - Mature, battle-tested (20+ years)
# - Low resource footprint (~2MB per 1000 connections)
# - Transparent to the application
#
# Cons:
# - Cannot pool prepared statements across clients (Rails uses them)
# - SET commands leak between transactions in transaction mode
# - No query-level intelligence — it pools, nothing more
# - Each database needs a separate PgBouncer pool configuration
# - With 4 Solid Trifecta databases, you need 4 PgBouncer pools
# - Does not understand Rails connection semantics
#
# For a 4-database Rails 8 setup, PgBouncer configuration
# becomes non-trivial. Four [databases] sections, four pool
# configurations, careful tuning of server_pool_size for each. PgBouncer in transaction mode is the standard recommendation, and it works. For a single-database Rails application, the setup is straightforward: point your DATABASE_URL at PgBouncer, configure the upstream pool size, and enjoy connection multiplexing.
For a four-database Rails 8 application, the setup is considerably more involved. You need four PgBouncer pools, each with its own upstream configuration. You need to decide whether the four pools share a PgBouncer instance or run separately. You need to handle the prepared statement issue — Rails uses prepared statements by default, and PgBouncer in transaction mode cannot share them across clients. The standard workaround is to disable prepared statements entirely (prepared_statements: false in database.yml), which is functional but sacrifices the 5-15% query parsing speedup that prepared statements provide.
PgBouncer also makes no distinction between a primary database connection serving a web request and a cache database connection performing a sub-millisecond lookup. It pools all connections with the same priority and the same lifecycle. This is correct from PgBouncer's perspective — it operates at the wire protocol level and has no concept of your application's priorities — but it means that a burst of cache operations can starve primary query connections or vice versa.
None of this means PgBouncer is the wrong choice. It means PgBouncer with four databases is a different beast than PgBouncer with one, and the configuration effort scales accordingly. If your team has PgBouncer expertise, use it. If you are adopting it for the first time specifically to solve the Solid Trifecta connection problem, be prepared for a steeper learning curve than the blog posts suggest.
What Gold Lapel does with 284 connections
Right-sizing pools helps. PgBouncer helps. But both approaches share a fundamental limitation: they require you to understand the connection topology and manually maintain the balance. Add a server, retune the pools. Scale Solid Queue workers for a traffic spike, retune the pools. Raise Puma threads after a profiling session, retune the pools. It is a spreadsheet masquerading as infrastructure.
Gold Lapel eliminates the spreadsheet.
# With Gold Lapel: gem "goldlapel-rails" — auto-patches ActiveRecord.
# GL multiplexes all connections through a smaller upstream pool.
#
# Before (4 databases, 284 connections to PostgreSQL):
#
# App Server 1 ──── 83 connections ──── PostgreSQL
# App Server 2 ──── 83 connections ──── PostgreSQL
# Solid Queue ──── 75 connections ──── PostgreSQL
# Action Cable ──── 43 connections ──── PostgreSQL
#
# After (4 databases, 284 app connections, ~40 PostgreSQL connections):
#
# App Server 1 ──── 83 connections ────┐
# App Server 2 ──── 83 connections ────┤
# Solid Queue ──── 75 connections ────┼── Gold Lapel ── ~40 ── PostgreSQL
# Action Cable ──── 43 connections ────┘
#
# Your Rails pools stay the same size.
# Your database.yml barely changes.
# PostgreSQL sees 40 connections instead of 284.
# config/database.yml — with Gold Lapel
production:
primary:
<<: *default
url: <%= ENV["GOLDLAPEL_PRIMARY_URL"] %> # points to GL proxy
pool: <%= ENV.fetch("RAILS_MAX_THREADS", 5) %>
queue:
<<: *default
url: <%= ENV["GOLDLAPEL_QUEUE_URL"] %>
pool: <%= ENV.fetch("RAILS_MAX_THREADS", 5) %>
migrations_paths: db/queue_migrate
cache:
<<: *default
url: <%= ENV["GOLDLAPEL_CACHE_URL"] %>
pool: <%= ENV.fetch("RAILS_MAX_THREADS", 5) %>
migrations_paths: db/cache_migrate
cable:
<<: *default
url: <%= ENV["GOLDLAPEL_CABLE_URL"] %>
pool: <%= ENV.fetch("RAILS_MAX_THREADS", 5) %>
migrations_paths: db/cable_migrate Your Rails application connects to Gold Lapel instead of PostgreSQL directly. Gold Lapel accepts all 284 application connections — Puma, Solid Queue, Action Cable, console sessions, everything. On the other side, it maintains a small pool of actual PostgreSQL connections, typically 20-40 for the deployment that was demanding 284.
The connection multiplexing is transparent. Each Rails pool checks out a connection to Gold Lapel, which assigns a PostgreSQL backend connection for the duration of the transaction. When the transaction completes, the PostgreSQL connection returns to Gold Lapel's pool and is available for any other application connection. Four databases, dozens of processes, hundreds of application-side connections — all funneling through a pool sized for what PostgreSQL actually performs well with.
Unlike PgBouncer, Gold Lapel understands the query semantics. It knows that a SELECT 1 health check should not occupy the same priority queue as a user-facing transaction. It knows that Solid Queue's polling queries are repetitive and predictable. It knows that cache reads are fast and should be served from the shortest available connection. This is not generic connection pooling — it is connection management with awareness of what the connections are doing.
You do not need to calculate per-database pool sizes. You do not need to re-derive the formula when you scale Puma workers or add Solid Queue processes. You do not need to raise max_connections and hope for the best. Gold Lapel holds the application side at whatever size Rails wants and the PostgreSQL side at whatever size the hardware warrants.
The honest counterpoint: when this does not apply
I should be forthcoming about the situations where this entire analysis is less relevant, because pretending they do not exist would be a disservice to you.
If you are not using the Solid Trifecta. If you use Sidekiq with Redis for background jobs, Memcached for caching, and Redis for Action Cable, you have one database with one pool. The connection math is simple: puma_workers x puma_threads. A 2-worker, 5-thread Puma deployment needs 10 connections. PostgreSQL's default of 100 handles this comfortably. The Solid Trifecta connection problem is specific to the Solid Trifecta.
If you are running SQLite for non-primary databases. Rails 8 supports SQLite for Solid Queue, Solid Cache, and Solid Cable. If you use PostgreSQL only for your primary database and SQLite for the other three, you have one PostgreSQL connection pool. The multiplication effect disappears. SQLite has its own scaling limitations, but connection exhaustion against PostgreSQL is not one of them.
If your deployment is genuinely small. A single server running Puma with 2 workers and 3 threads, Solid Queue with 1 process and 3 threads, and the Solid Trifecta pointing at a single PostgreSQL instance needs roughly 64 connections. PostgreSQL's default of 100 handles this with 36 connections of headroom. You will not hit the limit until you scale, and you may not scale for a long time. The math matters most at the transition from "one server, comfortable" to "two servers, suddenly out of connections."
For everyone else — and that includes any team running more than one server, or planning to — the formula applies, the numbers are real, and the 3 AM page is a matter of when, not if.
The Solid Trifecta deserves better arithmetic
The Solid Trifecta is a good idea. I mean that without reservation. Replacing three Redis dependencies with PostgreSQL-backed alternatives reduces operational complexity, eliminates an entire category of data consistency bugs, and simplifies your deployment topology. The engineering behind Solid Queue, Solid Cache, and Solid Cable is thoughtful and well-executed.
The connection math is the only awkward part. It is not a flaw in the architecture — it is an inevitable consequence of running four databases where you previously ran one. The solution is not to avoid the Solid Trifecta. The solution is to do the arithmetic before your users do it for you, involuntarily, at the worst possible moment.
Measure your actual connections with pg_stat_activity. Right-size your pools by database and by process type. Account for health checks, deploys, and console sessions. Consider a connection pooler — whether PgBouncer, pgcat, or Gold Lapel — when the numbers exceed what right-sizing alone can manage.
And if you take nothing else from this article, take the formula. Write it on a whiteboard. Tape it to your monitor. workers x threads x databases x servers. The rest is detail. That multiplication is the whole story.
In infrastructure, the most dangerous number is the one you never calculated.
Frequently asked questions
Terms referenced in this article
Before you take your leave, I should mention that Solid Queue's own PostgreSQL behaviour has some surprises worth understanding. I have prepared a dedicated guide on tuning Solid Queue's FOR UPDATE SKIP LOCKED on PostgreSQL — the connection pool arithmetic lands rather differently once you see what each worker is actually doing.