Solid Queue in Rails 8: Setup Notes and Trade-offs
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.
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_jobsstores all job data (class, arguments, priority, queue).solid_queue_ready_executionsholds jobs ready to run.solid_queue_claimed_executionstracks jobs locked by a worker process.solid_queue_blocked_executionsholds jobs waiting on concurrency limits.solid_queue_scheduled_executionsstores jobs scheduled for future execution.solid_queue_failed_executionsrecords failed jobs with error details.solid_queue_recurring_executionsandsolid_queue_recurring_tasksmanage cron-style recurring jobs.solid_queue_pausestracks paused queues.solid_queue_processesregisters running worker/dispatcher processes with heartbeats.solid_queue_semaphoresimplements 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:installconfigures 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_jobsis the central table - all execution tables reference it viajob_idforeign keys withON DELETE CASCADE- Each execution type (
ready,claimed,blocked,scheduled,failed,recurring) has a unique index onjob_id- a job can only be in one execution state at a time solid_queue_semaphoresimplements concurrency control using database-level row lockingsolid_queue_processestracks 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_queueon[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:
- Copy the contents of
db/queue_schema.rbinto a normal migration (this is the migration people often nameCreateSolidQueueTables), then deletedb/queue_schema.rb. - Remove
config.solid_queue.connects_tofromconfig/environments/production.rb. - 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 processprocesses- Number of worker processes per queuepolling_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
- Migrating from Sidekiq to Solid Queue
- Solid Cache in Rails 8: When the Database Is the Right Cache
- Rails PostgreSQL Performance: Start With the Query Plan - keep the database fast when jobs and web traffic share it
- How to Deploy Rails 8 Apps with Kamal to a VPS
- Rails AI Agents with the Anthropic SDK - a common Solid Queue workload: running AI agents as background jobs
- Solid Queue GitHub Repository
- Mission Control - Jobs
- Rails 8.0 Release Notes