The portfolio shipped in May with two things bothering me. The chat panel disappeared when you clicked it. And the whole site looked like every other AI-generated landing page — purple gradients on dark mesh, glass blur cards, the lot. I spent a weekend fixing both, and the two problems turned out to be tangled in the same root cause: I had stopped paying attention to my own defaults.
Wait — wrong post. Here’s the right opening.
If you run a Rails app on a single box, the background job story has always cost you a second daemon. Sidekiq means Redis, and Redis means another process to install, monitor, back up, and reason about when memory climbs at 3am. For a solo deploy that ratio — one app, two datastores — never sat right with me. Rails 8 makes Solid Queue the default Active Job backend, which means the queue lives in the database you already run. This post is the rubric I use to decide whether that swap is safe in production, how to lay it out on one machine, and the specific places it bites back. No Redis, one Postgres, one supervisor process. That’s the shape we’re aiming for.
What Solid Queue actually is
Solid Queue is a database-backed Active Job backend that ships in the box with Rails 8. Instead of pushing jobs into Redis, it writes rows to your relational database and pulls them back out with FOR UPDATE SKIP LOCKED — the same locking primitive Postgres gives you for any work-queue table, now wrapped in a maintained library so you don’t write the polling loop yourself.
It is not a thin shim. Solid Queue runs its own supervisor that manages worker, dispatcher, and scheduler processes, supports concurrency controls, recurring (cron-style) jobs, and per-queue prioritisation. Mission Control — Jobs gives you a web UI to inspect failures and retry, filling the role Sidekiq’s dashboard used to.
The scope I care about here is narrow: production, on one box, where the database is the constraint you already accept. If you’re running a managed Postgres and a single app server — the one-box deploy I default to — Solid Queue removes a moving part rather than adding one. That’s the whole pitch.
The decision rubric
The honest question isn’t “is Solid Queue good” — it’s “does my workload fit a database queue.” Sidekiq earns its Redis dependency at high throughput. Most solo and early-stage apps never reach that throughput. Here’s how I size it up:
| Factor | Lean Solid Queue | Lean Sidekiq + Redis |
|---|---|---|
| Job volume | Up to low thousands/min | Sustained tens of thousands/min |
| Latency need | Seconds is fine | Sub-second pickup matters |
| Boxes | One, maybe two | Many workers, horizontal scale |
| Ops appetite | Want fewer daemons | Already run Redis anyway |
| Job payload | Small, idempotent | Same |
| DB headroom | Postgres has spare IOPS | DB already saturated |
The mechanic that makes this work on one box is the supervisor. A single bin/jobs process forks workers, the dispatcher (which moves scheduled jobs into the ready set), and the scheduler (recurring tasks). You run that under the same process manager as Puma, and you’re done.
Two run modes matter. You can run jobs inside Puma via the SOLID_QUEUE_IN_PUMA plugin — zero extra processes, fine for genuinely light workloads. Or run bin/jobs as its own supervised process, which is what I reach for the moment job work could starve web requests. The in-Puma mode is seductive because it’s one fewer thing; it’s also the first thing I rip out when a slow job starts eating a web worker’s time.
Pitfalls and anti-patterns
The failure mode nobody warns you about is connection pool math. Each Solid Queue worker thread checks out a database connection, and so does each Puma thread. On one box those pools add up fast. Set RAILS_MAX_THREADS and your worker concurrency deliberately, then check that Postgres max_connections covers the sum with margin. The default pool size is a number you copied once and forgot — go look at it.
The second trap is treating the queue database like it’s free. It isn’t. Every job is an insert plus a delete, and a busy app generates dead tuples that Postgres has to vacuum. If you put the queue in your main database and never tune autovacuum, the queue tables bloat and your application queries slow down in sympathy. The separate-database move from above is the cheap insurance.
The third anti-pattern is skipping idempotency because “the database is reliable now.” A worker can die mid-job and the row gets retried. Solid Queue’s locking prevents two workers grabbing the same job, but it cannot make your job safe to run twice. That’s still on you. If you’ve read my note on zero-downtime migrations, the same instinct applies: assume the operation runs more than once and design so that’s harmless.
A worked example, conceptually
Here’s how the layout looks for a typical Rails 8 SaaS on a single VPS — described as a pattern, not a project I’m billing for.
You declare a second database for the queue in config/database.yml:
production:
primary:
<<: *default
database: app_production
queue:
<<: *default
database: app_queue_production
migrations_paths: db/queue_migrate
Active Job points at Solid Queue, and Solid Queue points at the queue database via config/solid_queue.yml, where you also define your queues and worker counts:
production:
dispatchers:
- polling_interval: 1
batch_size: 500
workers:
- queues: [critical, default]
threads: 3
processes: 1
- queues: low
threads: 1
processes: 1
Then bin/jobs runs as its own service under Kamal (or systemd, or a Procfile entry) right alongside Puma. One container image, two processes, one Postgres server with two logical databases.
The reasoning behind the split queues: critical for things a user is waiting on — a password reset email, a webhook ack — and low for the batch work that can lag a minute without anyone noticing. Giving the critical queue its own worker with more threads means a flood of low-priority jobs can’t delay the email someone is staring at their inbox for.
The right number of background daemons for a solo app is the smallest number that still lets a slow job fail without taking the web tier down with it.
That’s the entire architecture. No Redis to provision, no separate metrics endpoint to scrape, no second backup target. When I size a Rails MVP for 4–8 weeks, this is the default I start from and only deviate from with evidence.
What done looks like
You’ve shipped it correctly when four things are true. First, bin/jobs is supervised — if it crashes, your process manager restarts it, and you have an alert when it flaps. Second, the connection pool arithmetic is written down somewhere, not discovered during an incident: Puma threads plus worker threads is comfortably under max_connections.
Third, you can answer “what’s failing right now” in under a minute, because Mission Control — Jobs is mounted behind your admin auth and you actually look at it. A queue you can’t observe is a queue that’s lying to you.
Fourth — and this is the one people skip — you’ve watched the queue tables under real load for a few days and confirmed autovacuum is keeping up. Row count for solid_queue_ready_executions should hover near zero between bursts, not creep upward. If it creeps, your workers can’t keep pace and you need more threads or a faster job, not a bigger machine.
When this doesn’t apply
Skip Solid Queue if your database is already the bottleneck. Adding queue write churn to a Postgres that’s pegged on IOPS is how you turn one problem into two. Skip it if you genuinely sustain tens of thousands of jobs a minute — at that volume Redis’s in-memory speed is worth the operational cost, and Sidekiq’s maturity at scale is real.
And skip it if you already run Redis for caching or Action Cable and it’s healthy. The win here is removing a dependency; if Redis is staying anyway, the calculus changes. I worked through that specific fork in Sidekiq vs Solid Queue in 2026.
The falsifiable bit
Here’s the claim, stated so it can be wrong: for a Rails 8 app doing under a few thousand background jobs a minute on a single box, moving from Sidekiq to Solid Queue removes a process without measurably raising job latency — provided the queue lives in its own database and autovacuum keeps up. If you run that workload, split the database, watch solid_queue_ready_executions, and still see latency climb or web requests starve, I’m wrong about your case and Redis is earning its keep. Measure it before you believe either of us.