Sizing ActiveRecord Pools for Sidekiq Workers
This guide is part of Rails ActiveRecord Connection Pool. Sidekiq runs jobs on a pool of threads inside one Ruby process, and each thread that touches ActiveRecord checks out its own database connection. When the web tier’s database.yml pool setting is reused unchanged for Sidekiq, or when concurrency is raised for a busy queue without touching the pool, jobs start failing in a way that looks like a database problem but is purely local to the process:
ActiveRecord::ConnectionTimeoutError: could not obtain a connection from the pool within 5.000 seconds
(waited 5.002 seconds); all pooled connections were in use
app/jobs/invoice_pdf_job.rb:14:in `perform'
sidekiq-7.3.2/lib/sidekiq/processor.rb:202:in `execute_job'
The objective is to size the pool so every Sidekiq thread — across all capsules, plus any threads jobs spawn themselves — can hold a connection, and to make sure the resulting fleet total still fits the database.
Rapid incident diagnosis
Compare concurrency with pool size in the running process. Both values can differ from what the configuration files suggest, because environment variables override them. From a console attached to a worker container:
Sidekiq.default_configuration.capsules.transform_values(&:concurrency)
# => {"default"=>12, "reports"=>2}
ActiveRecord::Base.connection_pool.size
# => 5
ActiveRecord::Base.connection_pool.stat
# => {size: 5, connections: 5, busy: 5, dead: 0, idle: 0, waiting: 9, checkout_timeout: 5.0}
A total concurrency of 14 against a pool of 5, with 9 waiting, is the whole incident.
Check for threads jobs create themselves. A job that uses Thread.new, Concurrent::Promises.future, the parallel gem or load_async borrows extra connections from the same pool while the job’s own thread may already hold one. Search the code:
grep -rnE "Thread\.new|Concurrent::(Promises|Future)|Parallel\.(map|each)|load_async" app/jobs app/services
Rule out the database. If the pool is the limit, PostgreSQL sees at most pool sessions from each worker process and they are mostly active. If it sees fewer, or sessions are idle in transaction, the problem is a job holding a transaction open across a slow external call — a pool increase will not help:
SELECT client_addr, state, count(*), max(now() - xact_start) AS longest_txn
FROM pg_stat_activity
WHERE application_name LIKE 'sidekiq%'
GROUP BY client_addr, state;
Distinguish from Redis. Sidekiq also fails when Redis is slow, but those errors are RedisClient::ReadTimeoutError or Sidekiq::Shutdown, never ActiveRecord::ConnectionTimeoutError.
Mathematical sizing or parameter formula
The per-process requirement is the number of threads that can hold a connection at the same moment:
pool ≥ Σ capsule_concurrency + max_extra_threads_per_job × jobs_that_spawn_threads + 1
The + 1 covers Sidekiq’s own work on the main thread and any instrumentation that touches the database outside jobs. Worked example: a default capsule of 12, a reports capsule of 2, and one report job type that uses load_async for up to 3 queries at once, of which at most 2 can run simultaneously (the capsule’s concurrency). The pool needs 12 + 2 + 3 × 2 + 1 = 21.
Rails also runs load_async queries on a separate executor sized by config.active_record.global_executor_concurrency; those queries borrow from the same pool, which is why they belong in the formula.
Then check the fleet. Every Sidekiq process opens up to its pool size, so:
worker_connections = containers × processes_per_container × pool
With 6 worker containers running one process each, the pool of 21 claims 126 connections — more than the whole web tier might use. If the database budget for workers is 80, the options, in order, are: lower concurrency on queues that are mostly waiting on HTTP rather than the database, move thread-spawning work into separate jobs, reduce the container count and raise per-process concurrency only where the database can take it, or put PgBouncer in front. Raising pool alone never helps once the fleet total is the constraint.
Exact remediation & configuration
Give Sidekiq its own pool setting. Drive both concurrency and pool from one variable per process type so they cannot drift apart:
# config/database.yml
production:
primary:
adapter: postgresql
url: <%= ENV["DATABASE_URL"] %>
pool: <%= ENV.fetch("DB_POOL") { ENV.fetch("RAILS_MAX_THREADS", 5).to_i + 1 } %>
checkout_timeout: 5
idle_timeout: 300
reaping_frequency: 60
variables:
application_name: <%= ENV.fetch("APP_ROLE", "puma") %> # APP_ROLE=sidekiq on worker containers
# config/initializers/sidekiq.rb
Sidekiq.configure_server do |config|
config.concurrency = Integer(ENV.fetch("SIDEKIQ_CONCURRENCY", 12))
config.capsule("reports") do |cap|
cap.concurrency = 2
cap.queues = %w[reports]
end
total = config.capsules.values.sum(&:concurrency)
pool = ActiveRecord::Base.connection_pool.size
if pool < total + 1
raise "ActiveRecord pool (#{pool}) smaller than Sidekiq concurrency (#{total}) + 1"
end
end
The guard fails the worker at boot instead of at 3 a.m. under load. In the deployment, set DB_POOL=21 and SIDEKIQ_CONCURRENCY=12 for worker containers, and leave the web containers on RAILS_MAX_THREADS.
Make job-spawned threads return their connections. Any thread a job creates must check out and return its own connection explicitly:
class InvoicePdfJob
include Sidekiq::Job
def perform(invoice_id)
invoice = Invoice.find(invoice_id)
pages = invoice.sections.map do |section|
Thread.new do
ActiveRecord::Base.connection_pool.with_connection do
SectionRenderer.new(section.reload).render # queries run on this thread's connection
end
end
end.map(&:value)
PdfStore.upload(invoice, pages) # no connection held during upload
end
end
Without with_connection, each spawned thread keeps its connection until the thread is garbage collected or the reaper notices it is dead, which during a busy period means the pool shrinks with every job.
Keep transactions away from slow calls. A job that wraps an HTTP request in ActiveRecord::Base.transaction holds its connection — and row locks — for the duration of the request. Do the external call first, then write inside a short transaction.
Cap the fleet. Tie the worker autoscaler’s maximum to the budget: max_containers = floor(worker_budget ÷ pool), here floor(80 ÷ 21) = 3. If three containers cannot keep up with queue latency, lower per-process concurrency on HTTP-heavy queues and scale containers, or add PgBouncer in transaction mode, following Configuring ActiveRecord with PgBouncer Transaction Mode.
Validation & verification
Boot guard. Deploy a worker with DB_POOL deliberately set below concurrency in staging; it must refuse to start with the guard’s message.
Pool stats under load. Enqueue a burst larger than total concurrency and sample the pool from inside a worker every few seconds — a small debug job that logs connection_pool.stat works:
class PoolStatJob
include Sidekiq::Job
sidekiq_options queue: "default"
def perform
Sidekiq.logger.info(pool: ActiveRecord::Base.connection_pool.stat)
end
end
waiting should stay at zero and busy should peak at or below total concurrency plus the async slots.
Fleet total on the database. During the burst, the worker session count must not exceed containers × pool:
SELECT count(*) AS sidekiq_sessions,
count(*) FILTER (WHERE state = 'idle in transaction') AS idle_in_txn
FROM pg_stat_activity
WHERE application_name = 'sidekiq';
No leaked thread connections. Run the thread-spawning job 200 times, then check connection_pool.stat[:connections]. It should settle back to the steady-state number within one reaping_frequency interval; a count that ratchets up with each batch means a thread is still borrowing without with_connection.
Queue latency. Finally, confirm that the fix did not trade errors for backlog: Sidekiq::Queue.new("default").latency should return to its normal range once the burst drains.
Separating queues by database cost
Sidekiq concurrency is usually set for throughput — how many jobs a process can run — but database connections are consumed only by jobs that touch the database, and not all jobs do. Mixing cheap and expensive jobs in one capsule forces the pool, and the fleet budget, to be sized for the worst case.
Classify queues by what their jobs wait on:
| Queue type | Typical wait | Connections per busy thread | Concurrency strategy |
|---|---|---|---|
| HTTP-bound (webhooks, email, third-party APIs) | Network | Near zero if queries happen before or after the call | High concurrency, small share of pool |
| Database-bound (imports, recalculations) | Database | One for the whole job | Low concurrency, sized from database capacity |
| Mixed (report generation) | Both | One, often held during rendering | Move rendering outside the connection |
Then give each class its own capsule, and — for HTTP-bound work — make sure jobs do not hold a connection while waiting on the network. ActiveRecord checks a connection out on the first query and, in Sidekiq, keeps it until the job finishes. For a job that loads a record, calls an API for two seconds, and saves, the connection is idle but held for those two seconds. Release it explicitly around the slow part:
class DeliverWebhookJob
include Sidekiq::Job
sidekiq_options queue: "webhooks"
def perform(delivery_id)
delivery = WebhookDelivery.find(delivery_id)
payload = delivery.payload
url = delivery.endpoint_url
# Return the connection to the pool before the network call
ActiveRecord::Base.connection_handler.clear_active_connections!
response = HTTP.timeout(10).post(url, json: payload)
WebhookDelivery.where(id: delivery_id).update_all(status: response.code, delivered_at: Time.current)
end
end
With that pattern, a webhooks capsule with concurrency 50 needs far fewer than 50 connections at any moment, because each job holds a connection only for its two short queries. The pool can be sized from measured busy connections rather than from thread count — but keep a guard, because a code change that adds a query during the network call silently restores the one-connection-per-thread requirement.
Sidekiq.configure_server do |config|
config.capsule("webhooks") { |cap| cap.concurrency = 50; cap.queues = %w[webhooks] }
config.capsule("imports") { |cap| cap.concurrency = 4; cap.queues = %w[imports] }
end
A reasonable starting point for such a mixed process is pool = imports concurrency + 25 % of webhooks concurrency + 1, verified by sampling connection_pool.stat[:busy] under production-like load and raising it if waiting is ever non-zero.
Frequently Asked Questions
Should the Sidekiq pool equal concurrency exactly?
Do multiple databases need separate sizing?
connects_to or a replica configuration, each database entry has its own pool per process, and each must be at least the concurrency that can use it. A job that reads from the replica and writes to the primary holds one connection from each.Does Sidekiq Enterprise’s swarm change the formula?
Why do jobs still time out after raising the pool?
ConnectionTimeoutError persists with a pool larger than concurrency, something outside job threads is holding connections — spawned threads without with_connection, or long transactions. The detection techniques in Detecting ORM Connection Leaks in Production apply directly.Is clear_active_connections! safe inside a job?
Related
- Rails ActiveRecord Connection Pool — the parent guide on ActiveRecord pool mechanics.
- Fixing ActiveRecord ConnectionTimeoutError in Puma — the web-tier counterpart of this incident.
- Closing ORM Connections in Background Job Workers — connection lifecycle across job frameworks.
- Calculating a Connection Budget Across Microservices — fitting web and worker fleets into one database.
- Sizing ActiveRecord Pools for Multiple Databases — the per-database arithmetic when a process holds several pools.