rails background-jobs performance

Solid Queue in Rails 8: Setup Notes and Trade-offs

37 min read

Install Solid Queue in Rails 8, configure recurring.yml and concurrency controls, decide between Puma and a separate jobs process, and understand the Redis trade-off.

Solid Queue architecture in Rails 8 showing database-backed job processing without Redis

The first Solid Queue app I was happy to keep simple had boring jobs: invoices, webhooks, CSV imports, daily reports, and a few cleanup tasks. Sidekiq would have handled all of it. The question was whether the extra Redis service, dashboard, backups, and deploy wiring were buying anything the app actually needed.

For that shape of Rails app, Solid Queue is enough. It runs Active Job on PostgreSQL or MySQL, gives you delayed jobs, recurring tasks, concurrency controls, and Mission Control, and keeps the job system inside the stack you already operate. The trade-off is that your database is now part of the queueing system, so worker counts, polling, indexes, and retention deserve real attention.

Version note: this post assumes Rails 8, the bin/rails solid_queue:install path for existing apps, config/queue.yml, config/recurring.yml, db/queue_schema.rb, the bin/jobs wrapper, and limits_concurrency for cross-process concurrency control.

This guide assumes you want to ship Solid Queue in an app people rely on, not only try the adapter locally. Still choosing a backend? Read Solid Queue vs Sidekiq vs GoodJob first.

The job shape Solid Queue fits

Solid Queue is the default background job backend in Rails 8. It runs Active Job on your existing PostgreSQL or MySQL database instead of Redis, using SKIP LOCKED polling to claim work safely across multiple workers. It supports recurring jobs, concurrency limits, priority queues, and delayed execution, with a Mission Control dashboard for monitoring.

That makes it a good fit for ordinary application work: emails, imports, webhooks, reports, billing jobs, and cleanup tasks where pickup latency is measured in seconds or hundreds of milliseconds rather than as part of an interactive UI path. The Redis saving is real only if Redis existed mainly for jobs. If Redis is also your cache, pub/sub layer, or already-operated shared service, Solid Queue removes less from the system.

The moving parts

Two things are worth pinning down before setup: how jobs run under Kamal, and what all those tables are for.

In Rails 8 with Kamal, setting SOLID_QUEUE_IN_PUMA=1 tells Puma to start and supervise the Solid Queue supervisor inside the web process. You can also call plugin :solid_queue directly in config/puma.rb, or gate it on ENV['PUMA_RUN_JOBS'] so only some servers pick up jobs. Whether you should run jobs in Puma at all is a trade-off covered further down.

bin/rails solid_queue:install writes the schema to db/queue_schema.rb, and loading it with bin/rails db:prepare creates 11 tables (the full schema is a few sections down). What each one is responsible for:

  • solid_queue_jobs stores all job data (class, arguments, priority, queue).
  • solid_queue_ready_executions holds jobs ready to run.
  • solid_queue_claimed_executions tracks jobs locked by a worker process.
  • solid_queue_blocked_executions holds jobs waiting on concurrency limits.
  • solid_queue_scheduled_executions stores jobs scheduled for future execution.
  • solid_queue_failed_executions records failed jobs with error details.
  • solid_queue_recurring_executions and solid_queue_recurring_tasks manage cron-style recurring jobs.
  • solid_queue_pauses tracks paused queues.
  • solid_queue_processes registers running worker/dispatcher processes with heartbeats.
  • solid_queue_semaphores implements concurrency control via database-level semaphores.

What Solid Queue Gives You, and What It Does Not

Solid Queue is a database-backed Active Job backend that ships with Rails 8. It gives you:

  • Delayed jobs - Schedule jobs for future execution
  • Recurring jobs - cron-like scheduling without cron
  • Concurrency control - Limit simultaneous jobs by type or arguments
  • Priority queues - Process critical jobs first
  • Built-in monitoring - Dashboard via Mission Control - Jobs

Solid Queue vs Sidekiq: Key Differences

Traditionally, background jobs in Rails meant Sidekiq + Redis. That's a proven pattern, but it comes with operational overhead that Solid Queue eliminates for most applications.

Feature Solid Queue (Rails 8) Sidekiq + Redis
Backend PostgreSQL (your existing DB) Redis (separate service)
Throughput Workload-dependent; benchmark against your queue database Workload-dependent; generally stronger for high-throughput queues
Job latency Polling-based; depends on queue config and database health Pub/sub-backed; usually lower pickup latency
Recurring jobs Built-in (recurring.yml) Requires sidekiq-cron gem
Cross-process concurrency limits Built-in (limits_concurrency) Sidekiq Enterprise (OSS has thread/process concurrency, not per-key limits)
Monitoring Mission Control (free) Sidekiq Web (free) / Pro ($)
Infrastructure cost No extra datastore when your database can absorb the queue Separate Redis service to operate
Transactional enqueue Yes (same DB transaction) No (separate datastore)
Setup complexity Minimal (ships with Rails 8) Moderate (Redis + gem config)

The throughput and latency rows above are directional, not benchmark results: real numbers swing with job weight, database, hardware, and polling interval. Treat the Redis-vs-database difference as an architectural trade-off, then benchmark your own workload before sizing on it.

GoodJob is the third option in this space. It's also Postgres-backed, but it leans on LISTEN/NOTIFY instead of pure polling, which changes the latency and connection trade-offs. If you're weighing all three, the GoodJob comparison walks through throughput at different scales.

The apps it fits

It fits SaaS applications with moderate job volume and relaxed pickup-latency requirements, and it is especially attractive for e-commerce and payment-adjacent flows where transactional enqueue matters: a job enqueued inside the same database transaction as the record it depends on cannot be orphaned by a rollback. If you run a startup, an internal tool, or anything where you would rather not operate one more service, that is the pitch. And if you are already on PostgreSQL, there is no new database to stand up at all.

What Solid Queue Isn't

It is not a Redis replacement for caching - Solid Cache covers that. It will not keep up with Sidekiq at millions of jobs per day. And it is not a message bus: pub/sub patterns belong in ActionCable or Kafka, not in a polled jobs table.

Architecture in Two Minutes

Solid Queue runs three kinds of processes over a handful of tables: workers that poll queues and run jobs, a dispatcher that routes jobs based on priority and concurrency rules, and a scheduler that fires recurring tasks on time. Knowing which process does what tells you which knob to turn when a queue backs up.

Three Core Actors

1. Workers - Process jobs from queues

# Each worker polls a queue
worker_1: polling "critical" queue
worker_2: polling "default" queue
worker_3: polling "mailers" queue

2. Dispatcher - Routes jobs to workers based on priority and concurrency rules

3. Scheduler - Enqueues recurring jobs at specified times

How Polling Works

Solid Queue uses PostgreSQL's SKIP LOCKED feature. Workers poll solid_queue_ready_executions (a dispatcher moves due scheduled jobs into that table first); the claim query is roughly:

-- Simplified: multiple workers can poll simultaneously without blocking.
-- The real query joins solid_queue_ready_executions and locks the claimed row.
SELECT * FROM solid_queue_ready_executions
WHERE queue_name = 'default'
ORDER BY priority ASC, job_id ASC
LIMIT 1
FOR UPDATE SKIP LOCKED;

SKIP LOCKED means two workers never claim the same ready execution at the same time, so you avoid the thundering-herd contention a plain FOR UPDATE would cause. It does not make execution exactly-once: a job can still be retried, or fail after a side effect has already happened, so write your jobs to be idempotent.

Single vs Separate Databases

Separate Queue Database (the Rails 8 generated default):

  • What solid_queue:install configures out of the box, and what the docs recommend
  • Isolates job processing from app queries
  • Keeps high-churn job tables off your primary database
  • Prevents job processing from blocking user requests
# config/database.yml
production:
  primary:
    <<: *default
    database: myapp_production

  queue:
    <<: *default
    database: myapp_queue_production
    migrations_paths: db/queue_migrate

Single Database (an explicit opt-in):

  • Fewer moving parts, one database to back up and monitor
  • Jobs share the connection pool with app queries
  • Fine for smaller apps, but a busy queue now competes with user traffic

Solid Queue's docs recommend the separate queue database, and that is what the installer wires up. If you deliberately want one database, take the single-database path covered below. Either way, watch for connection pool exhaustion or slow queries caused by job churn.

Getting Started (Rails 8 New App)

Rails 8 includes Solid Queue by default. For new apps, you're ready to go:

# Create new Rails 8 app
rails new myapp

# Solid Queue is already configured with a separate queue database.
# db:prepare creates that database and loads db/queue_schema.rb
bin/rails db:prepare

# Start job processor
bin/jobs

Adding to Existing Rails Apps

If you're upgrading an existing app:

# Add to Gemfile
gem 'solid_queue'

# Install
bundle install
bin/rails solid_queue:install

# solid_queue:install creates:
# - config/queue.yml       (worker/dispatcher configuration)
# - config/recurring.yml   (recurring/cron-style jobs)
# - db/queue_schema.rb     (schema for the queue database)
# - bin/jobs               (executable that runs the supervisor)
# It also adds config.solid_queue.connects_to to config/environments/production.rb

# Create the queue database and load its schema
bin/rails db:prepare

If you would rather keep everything in one database, see Single-database setup below; the steps differ.

The Solid Queue Schema

Here's the schema that bin/rails solid_queue:install writes to db/queue_schema.rb, with the indexes trimmed for readability (the generated file adds the polling indexes noted after the block). Loading it (with bin/rails db:prepare) creates these 11 tables:

# db/queue_schema.rb
# Generated by: bin/rails solid_queue:install
#
# Creates 11 tables for Solid Queue's database-backed job processing

create_table "solid_queue_jobs" do |t|
  t.string "queue_name", null: false
  t.string "class_name", null: false
  t.text "arguments"
  t.integer "priority", default: 0, null: false
  t.string "active_job_id"
  t.datetime "scheduled_at"
  t.datetime "finished_at"
  t.string "concurrency_key"
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
end

create_table "solid_queue_ready_executions" do |t|
  t.bigint "job_id", null: false
  t.string "queue_name", null: false
  t.integer "priority", default: 0, null: false
  t.datetime "created_at", null: false
end

create_table "solid_queue_claimed_executions" do |t|
  t.bigint "job_id", null: false
  t.bigint "process_id"
  t.datetime "created_at", null: false
end

create_table "solid_queue_blocked_executions" do |t|
  t.bigint "job_id", null: false
  t.string "queue_name", null: false
  t.integer "priority", default: 0, null: false
  t.string "concurrency_key", null: false
  t.datetime "expires_at", null: false
  t.datetime "created_at", null: false
end

create_table "solid_queue_scheduled_executions" do |t|
  t.bigint "job_id", null: false
  t.string "queue_name", null: false
  t.integer "priority", default: 0, null: false
  t.datetime "scheduled_at", null: false
  t.datetime "created_at", null: false
end

create_table "solid_queue_failed_executions" do |t|
  t.bigint "job_id", null: false
  t.text "error"
  t.datetime "created_at", null: false
end

create_table "solid_queue_recurring_executions" do |t|
  t.bigint "job_id", null: false
  t.string "task_key", null: false
  t.datetime "run_at", null: false
  t.datetime "created_at", null: false
end

create_table "solid_queue_recurring_tasks" do |t|
  t.string "key", null: false
  t.string "schedule", null: false
  t.string "command", limit: 2048
  t.string "class_name"
  t.text "arguments"
  t.string "queue_name"
  t.integer "priority", default: 0
  t.boolean "static", default: true, null: false
  t.text "description"
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
end

create_table "solid_queue_pauses" do |t|
  t.string "queue_name", null: false
  t.datetime "created_at", null: false
end

create_table "solid_queue_processes" do |t|
  t.string "kind", null: false
  t.datetime "last_heartbeat_at", null: false
  t.bigint "supervisor_id"
  t.integer "pid", null: false
  t.string "hostname"
  t.text "metadata"
  t.datetime "created_at", null: false
  t.string "name", null: false
end

create_table "solid_queue_semaphores" do |t|
  t.string "key", null: false
  t.integer "value", default: 1, null: false
  t.datetime "expires_at", null: false
  t.datetime "created_at", null: false
  t.datetime "updated_at", null: false
end

# All execution tables cascade-delete when the parent job is removed
add_foreign_key "solid_queue_blocked_executions", "solid_queue_jobs",
  column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_claimed_executions", "solid_queue_jobs",
  column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_failed_executions", "solid_queue_jobs",
  column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_ready_executions", "solid_queue_jobs",
  column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_recurring_executions", "solid_queue_jobs",
  column: "job_id", on_delete: :cascade
add_foreign_key "solid_queue_scheduled_executions", "solid_queue_jobs",
  column: "job_id", on_delete: :cascade

Key things to note about the schema:

  • solid_queue_jobs is the central table - all execution tables reference it via job_id foreign keys with ON DELETE CASCADE
  • Each execution type (ready, claimed, blocked, scheduled, failed, recurring) has a unique index on job_id - a job can only be in one execution state at a time
  • solid_queue_semaphores implements concurrency control using database-level row locking
  • solid_queue_processes tracks running workers and dispatchers via heartbeats for process supervision
  • Indexes are optimized for polling queries using SKIP LOCKED (e.g., index_solid_queue_poll_by_queue on [queue_name, priority, job_id])

Single-database setup

The default install puts Solid Queue in its own database. If you want the queue tables in your primary database instead, the Solid Queue docs give a small manual path:

  1. Copy the contents of db/queue_schema.rb into a normal migration (this is the migration people often name CreateSolidQueueTables), then delete db/queue_schema.rb.
  2. Remove config.solid_queue.connects_to from config/environments/production.rb.
  3. Run bin/rails db:migrate.

Because there's no second database, config/database.yml doesn't need a separate queue entry. This is the one flow where you run db:migrate rather than db:prepare, and where a CreateSolidQueueTables migration is yours to create by hand.

Basic Configuration

# config/queue.yml
production:
  dispatchers:
    - polling_interval: 1
      batch_size: 500
      concurrency_maintenance_interval: 300

  workers:
    - queues: critical
      threads: 5
      processes: 2
      polling_interval: 0.1

    - queues: default
      threads: 3
      processes: 3
      polling_interval: 1

    - queues: low_priority
      threads: 2
      processes: 1
      polling_interval: 5

Key settings:

  • threads - Concurrent jobs per process
  • processes - Number of worker processes per queue
  • polling_interval - How often to check for new jobs (seconds)

Development vs dedicated job processes

# config/queue.yml
development:
  workers:
    - queues: "*"  # Process all queues
      threads: 1
      processes: 1
      polling_interval: 2

production:
  workers:
    # Separate workers per queue for better control
    - queues: critical
      threads: 5
      processes: 2
    - queues: [default, mailers]
      threads: 3
      processes: 3

Running Jobs in Development

# Terminal 1: Rails server
bin/rails server

# Terminal 2: Job processor
bin/jobs

Or use the Puma plugin to run jobs in the same process:

# config/puma.rb
plugin :solid_queue

# Now jobs run automatically with Puma
# Great for development, be cautious under real traffic

Puma Plugin or a Separate bin/jobs Process?

The Puma plugin runs your jobs inside the web process; bin/jobs runs them in a dedicated supervisor with its own workers. Use the plugin in development and on small, low-traffic apps where one box does everything. Run a separate bin/jobs process once the app has real traffic, where you want jobs and web requests to fail, restart, and scale independently.

Factor Puma plugin (plugin :solid_queue) Separate bin/jobs process
Best for Development, hobby and low-traffic apps Dedicated job process, anything under real load
Process model Jobs share the Puma process Dedicated supervisor + worker processes
Resource isolation None - a runaway job starves web requests Full - a slow job can't block page loads
Scaling Tied to web concurrency Scale workers per queue (threads/processes)
Deploys/restarts Jobs restart on every web deploy Restart workers without dropping web traffic
Setup One line in config/puma.rb A jobs role in Kamal or a systemd unit

The trap is shipping the development setup to a live app: a single CPU-bound job in the Puma process will block request threads and show up as slow page loads, not as a queue backlog. Once you have real traffic, split them.

How Do You Run Recurring Jobs in Solid Queue?

Solid Queue handles recurring jobs natively through config/recurring.yml - no cron, no whenever gem, no external scheduler. You define schedules in YAML and Solid Queue's supervisor process runs them automatically. For the full scheduling reference - cron syntax, time zones, idempotency, and overlap handling - see the dedicated guide to Solid Queue recurring and cron jobs.

Basic Recurring Job

# config/recurring.yml
production:
  send_daily_summary:
    class: DailySummaryJob
    schedule: every day at 9am

  cleanup_old_sessions:
    class: SessionCleanupJob
    schedule: every 1 hour

  process_subscriptions:
    class: SubscriptionChargeJob
    schedule: every day at 2am
    queue: critical

  generate_reports:
    class: ReportGenerationJob
    schedule: "0 */4 * * *"  # Every 4 hours (cron syntax)

Idempotency Matters

Recurring jobs may run multiple times due to retries or scheduler issues. Make them idempotent:

# app/jobs/daily_summary_job.rb
class DailySummaryJob < ApplicationJob
  queue_as :default

  def perform
    today = Date.current

    # Only process if not already done today
    return if DailySummary.exists?(date: today, status: 'completed')

    # Create a record to track execution
    summary = DailySummary.create!(date: today, status: 'processing')

    begin
      # Generate summary
      users = User.active.includes(:transactions)
      data = generate_summary_data(users)

      # Save results
      summary.update!(
        data: data,
        status: 'completed',
        completed_at: Time.current
      )
    rescue => e
      summary.update!(status: 'failed', error: e.message)
      raise  # Re-raise to trigger retry
    end
  end
end

Recurring Jobs with Arguments

# config/recurring.yml
production:
  sync_user_data:
    class: UserSyncJob
    args: [{ force: true }]
    schedule: every 6 hours

  send_notifications:
    class: NotificationJob
    args: ["daily_digest"]
    schedule: every day at 8am

Example: Daily Reconciliation

# app/jobs/transaction_reconciliation_job.rb
class TransactionReconciliationJob < ApplicationJob
  queue_as :critical
  retry_on StandardError, wait: :polynomially_longer, attempts: 3

  def perform
    date = Date.current - 1.day

    # Skip if already reconciled
    return if Reconciliation.completed_for_date?(date)

    reconciliation = Reconciliation.create!(
      date: date,
      status: 'in_progress'
    )

    Transaction.unreconciled.find_each(batch_size: 500) do |transaction|
      ReconciliationService.process(transaction)
    end

    reconciliation.update!(
      status: 'completed',
      completed_at: Time.current
    )
  end
end
# config/recurring.yml
production:
  reconcile_transactions:
    class: TransactionReconciliationJob
    schedule: every day at 1am
    queue: critical

Retry Behavior

Important: Solid Queue doesn't handle retries itself. That's Active Job's responsibility:

class MyJob < ApplicationJob
  # Active Job retry configuration
  retry_on TimeoutError, wait: 5.minutes, attempts: 3
  retry_on ApiError, wait: :polynomially_longer, attempts: 5

  discard_on ActiveRecord::RecordNotFound

  def perform(user_id)
    # Job logic
  end
end

Limiting Concurrent Jobs

Solid Queue's concurrency controls prevent race conditions and resource exhaustion.

Limit Jobs by Type

# app/jobs/report_generation_job.rb
class ReportGenerationJob < ApplicationJob
  queue_as :default

  # Only 3 report jobs can run simultaneously
  limits_concurrency to: 3, key: -> { "report_generation" }

  def perform(user_id, report_type)
    # Generate CPU-intensive report
    user = User.find(user_id)
    ReportGenerator.create(user, report_type)
  end
end

Limit by Arguments (Per-Resource)

# app/jobs/invoice_export_job.rb
class InvoiceExportJob < ApplicationJob
  queue_as :default

  # Only 1 invoice export per account at a time
  limits_concurrency to: 1, key: -> (account_id) { "invoice_export_#{account_id}" }

  def perform(account_id)
    account = Account.find(account_id)

    # This can take several minutes
    InvoiceExporter.generate_all(account)
  end
end

Why this matters: without the limit, enqueueing 100 invoice exports for the same account means lock contention on the same rows, overlapping exports racing each other, and a pile of duplicate work. With it, subsequent jobs simply wait until the first completes.

Example: Payment Processing

# app/jobs/payment_processor_job.rb
class PaymentProcessorJob < ApplicationJob
  queue_as :critical

  # Only 1 payment per user at a time (prevent double-charging)
  limits_concurrency to: 1, key: -> (transaction_id) {
    transaction = Transaction.find(transaction_id)
    "payment_processing_user_#{transaction.user_id}"
  }

  retry_on PaymentGateway::TemporaryError,
           wait: :polynomially_longer,
           attempts: 5

  discard_on PaymentGateway::CardDeclined

  def perform(transaction_id)
    transaction = Transaction.find(transaction_id)

    # Process payment with gateway
    result = PaymentGateway.charge(
      amount: transaction.amount,
      token: transaction.payment_token
    )

    transaction.update!(
      status: 'completed',
      gateway_transaction_id: result.id
    )

    # Enqueue follow-up jobs
    SendReceiptJob.perform_later(transaction.id)
    UpdateAccountingJob.perform_later(transaction.id)
  end
end

Concurrency with Expiry

class ApiSyncJob < ApplicationJob
  # Limit to 5 concurrent, expire lock after 10 minutes
  limits_concurrency to: 5,
                     key: -> { "api_sync" },
                     duration: 10.minutes

  def perform
    # Sync data from external API
  end
end

Failure Handling & Manual Re-enqueue

Failed jobs stay in the database for inspection:

# In Rails console
failed_job = SolidQueue::Job.failed.last

# Inspect error
failed_job.error
failed_job.error_backtrace

# Fix data and retry
failed_job.retry

# Or discard permanently
failed_job.discard

Observability & Operations

Mission Control - Jobs

Rails 8 includes Mission Control - Jobs, a web dashboard for monitoring:

# Gemfile
gem 'mission_control-jobs'

# Mount in routes
Rails.application.routes.draw do
  mount MissionControl::Jobs::Engine, at: "/jobs"
end

Visit /jobs to see:

  • Active jobs - Currently processing
  • Scheduled jobs - Waiting to run
  • Failed jobs - With errors and backtraces
  • Recurring jobs - Schedule and last run
  • Queue stats - Throughput and latency

For securing the dashboard behind authentication, the console API, and alerting on queue depth, see the full Mission Control guide.

Dashboard Features

Retry/Discard Actions:

# From the UI, you can:
# - Retry failed jobs individually or in bulk
# - Discard jobs that shouldn't retry
# - View full error traces
# - Inspect job arguments

Queue Inspection:

  • See pending job counts per queue
  • Identify backlog issues
  • Monitor job processing rates
  • Track average execution time

Authentication

Protect your dashboard before deployment:

# config/routes.rb
authenticate :user, ->(user) { user.admin? } do
  mount MissionControl::Jobs::Engine, at: "/jobs"
end

# Or with basic auth
MissionControl::Jobs.username = ENV["JOBS_USERNAME"]
MissionControl::Jobs.password = ENV["JOBS_PASSWORD"]

AppSignal Integration

For alerting and history beyond what Mission Control shows on the dashboard:

# Gemfile
gem 'appsignal'

# config/initializers/appsignal.rb
Appsignal.configure do |config|
  config.active = true
  config.push_api_key = ENV['APPSIGNAL_PUSH_API_KEY']
end

AppSignal automatically tracks:

  • Job execution time
  • Failure rates
  • Queue depth
  • Error details

Set up alerts:

  • Notify when job queue depth > 1000
  • Alert on job failure rate > 5%
  • Warn if job execution time > 5 minutes

The Checks Before I Would Ship It

Before deploying Solid Queue, verify:

1. Database Setup

# Use separate queue database (recommended for high traffic)
# config/database.yml
production:
  primary:
    database: myapp_production
  queue:
    database: myapp_queue_production
    migrations_paths: db/queue_migrate

The installer points Solid Queue at that database using Rails config (this line is added to production.rb for you):

# config/environments/production.rb
config.solid_queue.connects_to = { database: { writing: :queue } }

2. Indexes

Solid Queue's schema includes the necessary indexes, but verify:

# db/queue_schema.rb should include:
# - Index on queue_name, scheduled_at, priority
# - Index on key (for concurrency control)
# - Index on active_job_id
# - Index on concurrency_limit_value

3. Worker Thread Counts

Match your workload:

# config/queue.yml
production:
  workers:
    # CPU-intensive jobs: fewer threads
    - queues: reports
      threads: 2
      processes: 2

    # I/O-bound jobs: more threads
    - queues: [mailers, api_calls]
      threads: 10
      processes: 2

    # Mixed: moderate threads
    - queues: default
      threads: 5
      processes: 3

Rule of thumb:

  • CPU-intensive: threads ≤ CPU cores
  • I/O-bound: threads = 2-5x CPU cores
  • Mixed: threads = 1-2x CPU cores

4. Graceful Shutdown

Ensure jobs complete before restart:

# config/puma.rb
on_worker_shutdown do
  SolidQueue.supervisor.stop
end

# Or if using separate process manager
# Set shutdown timeout to 30-60 seconds

5. Health & Readiness Probes

# config/routes.rb
get '/health/jobs', to: 'health#jobs'

# app/controllers/health_controller.rb
class HealthController < ApplicationController
  def jobs
    # Check if workers are processing
    active_workers = SolidQueue::Worker.active.count

    # Check queue depths
    critical_depth = SolidQueue::Job.where(queue_name: 'critical').pending.count

    if active_workers > 0 && critical_depth < 1000
      render json: { status: 'ok' }, status: :ok
    else
      render json: {
        status: 'unhealthy',
        active_workers: active_workers,
        critical_queue_depth: critical_depth
      }, status: :service_unavailable
    end
  end
end

6. Rolling Restarts

For zero-downtime deploys:

# Kamal config
# config/deploy.yml
service: myapp

servers:
  web:
    - 192.168.1.1
  jobs:
    hosts:
      - 192.168.1.2
    cmd: bin/jobs

proxy:
  ssl: true
  host: app.example.com

accessories:
  postgres:
    image: postgres:16

Or with systemd:

# /etc/systemd/system/solid-queue.service
[Unit]
Description=Solid Queue Worker
After=network.target

[Service]
Type=simple
User=deploy
WorkingDirectory=/var/www/myapp
ExecStart=/usr/local/bin/bundle exec bin/jobs
ExecReload=/bin/kill -USR1 $MAINPID
KillMode=mixed
TimeoutStopSec=60

[Install]
WantedBy=multi-user.target

7. Backups

Your job queue is in PostgreSQL, so:

# Backup includes jobs
pg_dump myapp_production > backup.sql

# Or separate queue database
pg_dump myapp_queue_production > queue_backup.sql

8. Known Gotchas

These come up repeatedly in the project's GitHub issues, and each is cheaper to check before launch than after:

Connection Pool Exhaustion:

# Ensure pool size accommodates workers
# config/database.yml
production:
  queue:
    pool: <%= ENV.fetch("SOLID_QUEUE_POOL_SIZE", 25) %>

Long-Running Jobs:

# Jobs holding DB connections for hours
# Use streaming or break into smaller jobs
class HugeReportJob < ApplicationJob
  def perform(user_id)
    User.find_each(batch_size: 100) do |user|
      ProcessUserReportJob.perform_later(user.id)
    end
  end
end

Clock Drift:

# Ensure servers are time-synced
# Use NTP or cloud provider time sync

Why Does solid_queue_ready_executions Grow, and How Do You Keep It Small?

solid_queue_ready_executions stays small in row count - a row is inserted when a job becomes ready and deleted the moment a worker claims it - but it's one of the highest-churn tables in your database. That constant insert/delete cycle leaves dead tuples behind, and on a busy queue PostgreSQL's autovacuum can fall behind, bloating both the heap and its indexes until claim queries that should be index-only start wading through dead rows. This is the post-launch surprise: the queue "looks empty" in Mission Control while the table on disk is several gigabytes.

There are two distinct problems, and they have different fixes.

1. Finished jobs accumulating in solid_queue_jobs. By default preserve_finished_jobs is true, so every completed job stays in solid_queue_jobs after it runs. Recent versions of the install generator add a recurring cleanup task for you, but apps that ran solid_queue:install early (or deleted the task) never get it. Confirm it's in your config/recurring.yml:

# config/recurring.yml
production:
  clear_solid_queue_finished_jobs:
    command: "SolidQueue::Job.clear_finished_in_batches(sleep_between_batches: 0.3)"
    schedule: every hour at minute 12

The retention window is controlled separately. The default keeps finished jobs for one day; lower it on high-volume apps, or turn preservation off entirely if you don't inspect succeeded jobs:

# config/application.rb
config.solid_queue.clear_finished_jobs_after = 1.day  # default; drop to a few hours under load
config.solid_queue.preserve_finished_jobs   = true    # set false to delete on completion

Note that clear_finished_in_batches only removes finished (succeeded) jobs. Rows in solid_queue_failed_executions persist by design until you retry or discard them, so a bad deploy that fails ten thousand jobs leaves them all on disk. Prune failures from Mission Control or the console once you've triaged them:

# Discard all currently-failed jobs after triage
SolidQueue::FailedExecution.find_each(&:discard)

2. Bloat on the high-churn execution tables. Clearing finished jobs frees rows, but it doesn't fix index bloat from the insert/delete churn on solid_queue_ready_executions and solid_queue_claimed_executions. The fix is to let autovacuum run far more aggressively on those tables than the database-wide default:

-- Make autovacuum keep up with the churn on the ready queue
ALTER TABLE solid_queue_ready_executions SET (
  autovacuum_vacuum_scale_factor = 0.02,  -- vacuum at 2% dead tuples, not the default 20%
  autovacuum_vacuum_cost_delay   = 0       -- don't throttle the vacuum on this table
);

Check for accumulated bloat before assuming it's fine:

SELECT relname, n_live_tup, n_dead_tup, last_autovacuum
FROM pg_stat_user_tables
WHERE relname LIKE 'solid_queue_%'
ORDER BY n_dead_tup DESC;

If n_dead_tup dwarfs n_live_tup on the execution tables, a one-off VACUUM (VERBOSE, ANALYZE) solid_queue_ready_executions; reclaims the space; a REINDEX (or pg_repack to avoid the lock) handles index bloat that a plain vacuum won't. This is exactly the kind of slow-query and bloat work that becomes routine once your job tables share a database with user traffic.

When Should You Avoid Solid Queue?

There are workloads it genuinely does not fit:

1. Ultra-High Throughput

Scenario: Processing millions of jobs per day with strict latency requirements

Signal:

  • your queue table becomes a database hot spot before workers saturate
  • pickup latency matters more than transactional enqueue
  • the queue is a firehose of tiny jobs rather than ordinary application work

Solution: Use Sidekiq + Redis for high-volume queues

2. Hybrid Pattern

Keep Solid Queue for most jobs, isolate firehose queues to Redis:

Active Job lets a single job class use a different backend. Set self.queue_adapter on the class that should go through Redis (this requires the sidekiq gem installed and configured alongside Solid Queue):

# app/jobs/application_job.rb
class ApplicationJob < ActiveJob::Base
  # Most jobs use Solid Queue (the app-wide default)
end

# app/jobs/high_volume_job.rb
class HighVolumeJob < ApplicationJob
  self.queue_adapter = :sidekiq  # this class only, on Redis
  queue_as :firehose

  def perform(event_data)
    # Process high-volume events
  end
end

There's no separate adapter-mapping YAML file to maintain: the app-wide default stays config.active_job.queue_adapter = :solid_queue, and each class that overrides it declares its own self.queue_adapter.

3. User-Facing Latency Requirements

If job pickup latency is part of the user's visible interaction, Redis-backed queues are usually a better fit than a polling database queue. Solid Queue pickup time depends on polling interval, worker availability, and database health; Sidekiq can wake workers through Redis rather than waiting for the next database poll.

4. Specialized Job Features

Sidekiq Pro/Enterprise offers:

  • Batch job tracking
  • Rate limiting
  • Unique jobs (no duplicates)
  • Web throttling

Solid Queue is simpler but less feature-rich.

Migration Numbers

This is a worked example, not a client case study. The numbers use a common small-SaaS setup: two $40/month app servers, a $95/month managed Redis instance, and a job workload that peaks around 500 jobs per minute. The point is the shape of the trade-off, not a promise that every app saves exactly 63%.

Before (Sidekiq + Redis)

Infrastructure:

  • Rails app on 2x $40/month servers
  • Redis cluster: $95/month (managed)
  • Sidekiq workers: Shared with Rails processes

Performance:

  • Job processing: ~500 jobs/minute
  • Average job latency: 50ms (enqueue to start)
  • Monthly costs: $215 (servers + Redis)

Operational complexity:

  • Redis monitoring and alerts
  • Redis backup management
  • Connection pool tuning for both Postgres and Redis
  • Separate Sidekiq configuration

After (Solid Queue)

Infrastructure:

  • Rails app on 2x $40/month servers
  • PostgreSQL: Already included
  • Solid Queue workers: Integrated with Rails

Performance:

  • Job processing: ~500 jobs/minute (same workload)
  • Average job latency: 150ms (slightly higher, acceptable)
  • Monthly costs: $80 (just servers)

Operational complexity:

  • Single database to monitor
  • Unified backup strategy
  • One connection pool to tune
  • Built-in Mission Control dashboard

Net result: $135/month saved (a 63% reduction) and one fewer service to run, monitor, and back up. The cost was 100ms of extra job latency, which nothing in this app noticed, and a lower theoretical throughput ceiling that the workload never approached.

When Latency Matters

Redis-backed queues usually start jobs faster because workers are notified through Redis rather than waiting for the next database poll. Solid Queue pickup latency follows your polling interval, worker saturation, and queue database health (the comparison table above puts numbers on it). Whether that gap matters depends entirely on the job.

100ms latency is fine for:

  • Email sending (users won't notice)
  • Report generation (already takes minutes)
  • Data syncing (background task)
  • Cleanup jobs (periodic maintenance)

100ms latency is problematic for:

  • Real-time notifications (use ActionCable)
  • Payment processing feedback (critical path)
  • User-facing workflows (should be synchronous)

Solution: Keep latency-critical jobs in Redis, everything else in Solid Queue.

How It Fits the Rails 8 Stack

Solid Queue rarely ships alone. Solid Cache and Mission Control cover the other two jobs Redis used to do, and together they take the queue, the cache, and the monitoring dashboard off your infrastructure list.

Queue, Cache, and Dashboard

Solid Queue - Background jobs

# Replace Sidekiq + Redis
PaymentProcessorJob.perform_later(transaction_id)

Solid Cache - Application caching

# Replace Rails.cache + Redis
Rails.cache.fetch("user_#{user.id}_stats", expires_in: 1.hour) do
  expensive_calculation
end

Mission Control - Monitoring dashboard

# Replace Sidekiq Web + Redis Commander
mount MissionControl::Jobs::Engine, at: "/jobs"

The Result

Before Rails 8:

  • Rails app
  • Redis (Sidekiq)
  • Redis (Cache)
  • Sidekiq Web
  • Redis Commander
  • Separate monitoring

After Rails 8:

  • Rails app
  • PostgreSQL
  • Mission Control (built-in)
  • Unified monitoring

The decision I would make

Solid Queue is the right choice if you:

  • Run a typical SaaS, e-commerce, or B2B application
  • Run ordinary application jobs rather than a queue firehose
  • Value operational simplicity
  • Want to minimize infrastructure costs
  • Are building with a small team
  • Use PostgreSQL already

Stick with Sidekiq + Redis if you:

  • Treat the queue as a high-throughput subsystem
  • Need pickup latency inside a user-facing interaction
  • Require Sidekiq Pro/Enterprise features
  • Have existing Redis infrastructure
  • Need throughput a polling database queue cannot reach

For most small and mid-sized Rails apps, I would start with Solid Queue and make the database work visible from day one: separate the queue database if the app is busy, keep worker counts conservative, add Mission Control, alert on failed jobs and queue depth, and write every recurring job as if it can run twice.

I would keep Sidekiq when the queue is already a high-throughput subsystem, when Redis is already operated well, or when paid Sidekiq features are part of the product design. Treat the choice as an operations question rather than a framework identity question: do you want one database-backed system with polling and fewer moving parts, or a Redis-backed system with lower latency and a bigger queueing feature set?

If the app already runs Sidekiq, swapping the adapter is quick. The slow part is inventory: which jobs rely on Sidekiq retry defaults, which cron entries must not double-enqueue, and which queues need Redis-level latency. The migration runbook covers that cutover path in migrating from Sidekiq to Solid Queue.


If Solid Queue is a serious option for an existing app, I would inspect three things before changing the adapter: queue volume by class, whether any job depends on Redis-level latency, and what happens when a recurring job runs twice. I help with that review through background job architecture work.

Further Reading