Rails Solid Queue: Background Jobs in Postgres Without Redis or Sidekiq
A client asked me last month if they could kill their Redis instance. They were running a moderate Rails app — around twelve million jobs a month, nothing extreme — and the Redis node had become the part of their stack they thought about most. It had failed over twice in six months. It needed its own monitoring. It required a separate backup strategy that nobody was confident about. “We don’t use it for anything except Sidekiq,” they told me. “Can we just… not?”
After nineteen years of Rails I’ve watched the community oscillate between “databases are too slow for everything” and “the database should do everything.” Rails Solid Queue is the latest swing toward the second camp, and it’s the first time the trade-offs actually land on the right side for most applications. I’ve moved three production systems from Sidekiq to Solid Queue this year. Here’s what the move actually looks like, where it pays off, and where you should stay put.
What Rails Solid Queue Actually Is
Rails Solid Queue is a database-backed queuing backend for Active Job. It stores jobs in regular Postgres or MySQL tables and runs worker processes that SELECT FOR UPDATE SKIP LOCKED to pull work off the queue. That’s the whole trick. No Redis, no RabbitMQ, no external broker. The same Postgres instance that already runs your app now runs your job queue.
It ships by default with new Rails 8 applications. For existing apps you add the gem, install a separate database (recommended, not required), and configure Active Job to use it. Sidekiq-compatible patterns like concurrency limits, priority queues, scheduled jobs, and recurring tasks are all supported.
The feature list matters less than the operational story. For most teams, Rails Solid Queue means one fewer service to own.
When to Choose Rails Solid Queue Over Sidekiq
I give clients a simple decision rule: if your job throughput fits comfortably on one database, use Solid Queue. If it doesn’t, stay on Sidekiq.
Solid Queue is the right choice when:
- You process fewer than roughly 50 jobs per second sustained. Postgres can handle more, but past this point the tuning conversation gets real.
- Your jobs are mostly IO-bound (sending emails, calling APIs, generating PDFs) rather than compute-heavy batch work.
- You already run Postgres and want one fewer moving part.
- Operational simplicity matters more than a small performance advantage.
- You’re on Rails 7.1+ with Active Job.
Stay on Sidekiq when:
- You’re running 500+ jobs per second and every millisecond of dispatch latency shows up in user experience.
- You depend on Sidekiq Pro or Enterprise features (batches, rate limiters, leader election) that Solid Queue doesn’t match one-to-one.
- Your team has deep Sidekiq operational muscle memory and the Redis cost is invisible.
Most Rails apps I see do not hit the “stay on Sidekiq” bullets. The ones that do are usually obvious — ad-tech, high-volume ingestion, realtime analytics. For everyone else, Rails Solid Queue is a straight win.
Installing Rails Solid Queue
On a fresh Rails 8 app, it’s already there. For an existing app:
# Gemfile
gem "solid_queue"
gem "mission_control-jobs" # optional web UI
bundle install
bin/rails solid_queue:install
bin/rails db:migrate
The installer creates a separate database configuration block in config/database.yml for the queue. This is the setup I recommend — keep job tables out of your primary database so a runaway backlog never blocks your application queries.
# config/database.yml
production:
primary:
<<: *default
database: myapp_production
username: myapp
password: <%= ENV["DATABASE_PASSWORD"] %>
queue:
<<: *default
database: myapp_production_queue
username: myapp
password: <%= ENV["DATABASE_PASSWORD"] %>
migrations_paths: db/queue_migrate
Point Active Job at it:
# config/application.rb
config.active_job.queue_adapter = :solid_queue
config.solid_queue.connects_to = { database: { writing: :queue } }
That’s the entire install. Any ApplicationJob subclass you already have will start enqueueing to Postgres without further changes.
Configuring the Worker Process
Rails Solid Queue ships with a single supervisor process that manages workers, dispatchers, and scheduler threads. You configure it with a YAML file.
# config/queue.yml
default: &default
dispatchers:
- polling_interval: 1
batch_size: 500
workers:
- queues: "*"
threads: 5
processes: 2
polling_interval: 0.1
production:
<<: *default
workers:
- queues: [ critical, high ]
threads: 5
processes: 1
polling_interval: 0.1
- queues: [ default, low ]
threads: 10
processes: 2
polling_interval: 0.2
- queues: [ mailers ]
threads: 3
processes: 1
polling_interval: 0.5
The shape matters more than the specific numbers. You separate queues by priority because mixing a twenty-minute report job with user-facing email delivery is how you get complaints that “emails are slow” once a week. I’ve had that exact pager incident more than once over my career. Put slow jobs on their own pool.
Run the supervisor like any other long-lived process:
bin/jobs
In production with Kamal, systemd, or foreman, add it as a process type alongside the web server. No daemon fiddling, no Sidekiq-specific init scripts.
Concurrency Controls and Unique Jobs
This is where Rails Solid Queue closes a gap that used to force teams onto Sidekiq Pro. You can declare concurrency limits per job class:
class BillingReportJob < ApplicationJob
queue_as :default
limits_concurrency to: 1, key: ->(account) { account.id }, duration: 10.minutes
def perform(account)
account.generate_billing_report!
end
end
Two things happen with this declaration:
- Only one
BillingReportJobper account runs at a time across the entire fleet. - The lock expires after ten minutes to prevent a crashed worker from permanently blocking an account.
I’ve replaced tangled Redis-based mutex code in three apps with four lines of limits_concurrency. The correctness is guaranteed by Postgres row locks, which is a more auditable story than custom lock scripts in Redis.
Scheduled and Recurring Jobs
Cron-style recurring jobs live in config/recurring.yml:
production:
cleanup_expired_sessions:
class: SessionCleanupJob
queue: low
schedule: "every hour"
send_daily_digest:
class: DigestJob
queue: mailers
schedule: "at 8:00 every weekday"
args: [ "daily" ]
refresh_exchange_rates:
class: ExchangeRateJob
queue: default
schedule: "every 15 minutes"
No separate whenever gem. No sidekiq-cron. No server crontab to drift out of sync with deploys. The schedule ships with your code and is versioned alongside the job classes.
For one-off scheduled jobs, Active Job’s set(wait: 1.hour) and set(wait_until: ...) work exactly as you’d expect:
WelcomeSequenceStepJob.set(wait: 3.days).perform_later(user)
The dispatcher polls the solid_queue_scheduled_executions table every second by default and moves ready jobs into the ready queue. Latency is typically under two seconds between scheduled time and pickup, which is fine for anything that isn’t time-sensitive to the millisecond.
Rails Solid Queue Operational Tuning
A few things I’ve learned running Solid Queue in production:
Use a dedicated queue database. I mentioned this above but it’s worth repeating. A runaway job enqueue loop that writes a hundred thousand rows should not be able to bloat your user-facing query plans. Postgres autovacuum handles queue tables well, but isolate them anyway.
Tune polling_interval per queue. Critical queues get 0.1 seconds. Background maintenance queues can poll every two or three seconds — no user is waiting for them. Each poll is a cheap query, but at scale they add up.
Monitor solid_queue_failed_executions. Failed jobs land in a table, not in a Redis set that disappears on restart. Set up an alert for any row older than an hour. I use a tiny Prometheus exporter that runs SELECT COUNT(*) FROM solid_queue_failed_executions once a minute.
Watch the ready_executions queue depth. Growing depth means workers can’t keep up. The mission_control-jobs gem provides a web UI that shows this in real time:
# config/routes.rb
authenticate :user, ->(u) { u.admin? } do
mount MissionControl::Jobs::Engine, at: "/jobs"
end
Run the supervisor and workers as separate processes from the web server. In Kamal or Docker Compose, this means a workers role alongside web. Don’t be tempted to run bin/jobs inside your web container — sharing a process lifecycle makes deploys messier than they need to be.
Migrating from Sidekiq to Rails Solid Queue
The migration path I’ve used three times now:
- Install Solid Queue with a separate queue database.
- Leave
config.active_job.queue_adapter = :sidekiqin place. - Override the adapter per-job for a single low-risk job class:
class LowRiskJob < ApplicationJob
self.queue_adapter = :solid_queue
end
- Run both Sidekiq and Solid Queue workers in parallel for a week. Monitor error rates.
- Migrate job classes one at a time by removing the per-class override and flipping the global adapter once most jobs are moved.
- Drain Sidekiq, confirm empty queues, shut down Redis.
The per-class adapter override is the detail that makes this safe. You never have to do a big-bang switch, and you can roll back any individual job in under a minute.
For related production hardening, see my posts on Postgres connection pooling with PgBouncer and deploying Rails with Kamal 2 — both directly relevant when you’re running Solid Queue workers alongside your web fleet.
Common Pitfalls
Connection pool exhaustion. Each worker thread takes a database connection. Ten threads times two processes times two pools (primary + queue) equals forty connections just for Solid Queue. Size your Postgres connection limit accordingly or put PgBouncer in front.
Jobs that open their own transactions. If you wrap a job body in ActiveRecord::Base.transaction and also use limits_concurrency, you can end up with nested locks that surprise you. Read the generated SQL with ActiveRecord::Base.logger set to DEBUG before you ship the pattern widely.
Forgetting to run migrations on the queue database. bin/rails db:migrate only migrates the primary. You need bin/rails db:migrate:queue or the combined db:migrate with SCHEMA_FORMAT=sql. This has tripped up every team I’ve onboarded.
Assuming “Postgres means infinite capacity.” It doesn’t. Past about fifty sustained jobs per second, you’ll want to profile your specific workload. For heavier throughput, see the technical due diligence post for how I size these decisions against actual traffic patterns.
Frequently Asked Questions
Does Rails Solid Queue replace Sidekiq for all Rails apps?
No. Solid Queue is an excellent fit for the majority of Rails applications that run under fifty jobs per second and already use Postgres. For high-throughput systems (ad-tech, analytics, realtime ingestion) and teams depending on Sidekiq Pro or Enterprise features like batches and rate limiters, Sidekiq remains the stronger choice. The rule I use: if your current Redis instance is mostly quiet, you’re a Solid Queue candidate.
Can I use Rails Solid Queue with MySQL instead of Postgres?
Yes. Solid Queue supports MySQL 8+ and Postgres. Both use SELECT FOR UPDATE SKIP LOCKED for safe concurrent job pickup. Postgres is slightly more ergonomic because of its better index-only scan behavior on the queue tables, but MySQL works fine in practice. SQLite is also supported for development but not recommended for production job queues.
How does Rails Solid Queue handle failed jobs and retries?
Failed jobs land in the solid_queue_failed_executions table with their full error and backtrace. Active Job’s built-in retry_on and discard_on declarations work exactly as they do with any other adapter. Unlike Sidekiq’s dead set, failed jobs persist in the database until you explicitly retry or discard them. The mission_control-jobs web UI provides a one-click retry button per job.
What happens to scheduled jobs when a Solid Queue worker crashes?
Scheduled jobs live in the solid_queue_scheduled_executions table and survive any worker or supervisor crash because they’re in Postgres. When the supervisor restarts, the dispatcher picks up where it left off. This is one of the genuine improvements over Sidekiq — scheduled work in Sidekiq is held in Redis, so a Redis failure without persistence can lose scheduled jobs. Solid Queue inherits all the durability guarantees of your database backups.
Need help migrating Rails background jobs off Redis or scaling a production Rails stack? TTB Software specializes in Ruby on Rails architecture, performance, and fractional CTO work. We’ve been doing this for nineteen years.
About the Author
Roger Heykoop is a senior Ruby on Rails developer with 19+ years of Rails experience and 35+ years in software development. He specializes in Rails modernization, performance optimization, and AI-assisted development.
Get in TouchRelated Articles
Need Expert Rails Development?
Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise
Schedule a Free Consultation