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.

Sidekiq threads competing for a smaller ActiveRecord pool A Sidekiq process runs 12 default-capsule threads and 2 reports-capsule threads. The ActiveRecord pool holds 5 connections, all busy, so 9 threads wait and time out after 5 seconds. default capsule concurrency 12 reports capsule concurrency 2 Waiting in pool queue 9 threads fail after checkout_timeout 5 s ActiveRecord pool size 5 5 busy, 0 idle total concurrency 14 needs a pool of at least 14
Fourteen job threads compete for five connections. Nine threads wait in the pool queue, and any that wait longer than checkout_timeout fail their job with 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.

From Sidekiq concurrency to database connections Per process, 12 default threads plus 2 reports threads plus 6 async query slots plus 1 gives a pool of 21. Six containers of one process each claim 126 connections against a worker budget of 80. Per process 12 default threads 2 reports 6 async query slots +1 = 21 Fleet: 6 containers × 1 process × 21 worker budget 80 claim 126 once the fleet exceeds its budget, raising pool makes the incident worse
Pool size is a per-process figure. Capsules and job-spawned threads set it; containers and swarm processes multiply it into the number the database actually sees.

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.

Connection return for job-spawned threads A bare Thread.new holds its connection after the render finishes until the reaper runs. A thread using with_connection returns its connection as soon as the block completes, before the upload. Job thread holds 1 connection for Invoice.find Thread.new, no block connection kept after render until reaper runs with_connection block returned when render ends before PDF upload Pool of 21 bare threads shrink it job by job under load
Threads created inside a job need their own checkout. with_connection returns the connection when the block ends; a bare Thread.new keeps it until the thread is reaped.

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?
Concurrency plus one is the minimum, because a small amount of work outside job threads also uses the pool. Add the async and spawned-thread slots on top. Oversizing by a few costs nothing locally — unused connections are never opened — but it does raise the worst-case fleet claim the database must be able to accept.
Do multiple databases need separate sizing?
Yes. With 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?
Swarm runs several Sidekiq processes per container, each with its own pool. Multiply by the process count: a container with 4 swarm processes at a pool of 21 claims 84 connections.
Why do jobs still time out after raising the pool?
If 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?
It returns connections held by the current thread to the pool; the next query checks one out again. Do not call it inside a transaction block, where it would release a connection mid-transaction. Outside transactions it is safe and is the same mechanism Rails uses at the end of a request.