Scaling Ruby on Rails Past 10 Million Requests a Day
Most "how we scaled Rails" posts lead with a rewrite. Ours didn't need one. The monolith was fine — what wasn't fine was how we were using the database, the queue, and the deploy pipeline around it.
Where the time was actually going
A profiler will tell you the truth faster than an architecture diagram will. When we finally instrumented every request path, three things stood out immediately:
- N+1 queries hiding behind serializers — not the obvious ones in controllers, the ones three layers deep in a JSON serializer nobody had looked at in two years.
- Sidekiq queues with no priority separation — a slow analytics job could sit in front of a password-reset email.
- Connection pool starvation under bursty traffic, not steady load.
None of these show up in a load test that ramps traffic smoothly. They only show up when real users hit you unevenly, which is exactly when it matters most.
What we changed
We split the Sidekiq queue by priority and gave the low-latency queue its own dedicated worker pool. We added Bullet in CI so N+1 regressions fail the build instead of shipping. And we moved connection pool sizing from a static number to something that scaled with dyno count, not guesswork.
The queue split itself was a small config change — the win was entirely in separating the concurrency pools, not the priority number:
# config/sidekiq.yml
:queues:
- [critical, 4] # password resets, payment webhooks
- [default, 2]
- [analytics, 1] # never allowed to starve the queues above it
# app/workers/application_worker.rb
class ApplicationWorker
include Sidekiq::Worker
sidekiq_options queue: :default, retry: 3
def self.inherited(subclass)
super
subclass.sidekiq_options queue: :default
end
end
Pairing that with per-queue concurrency in Procfile (a dedicated critical-only worker process, separate from the general pool) is what actually stopped a slow analytics job from blocking a password reset — the priority number in the queue list does nothing on its own without dedicated capacity behind it.
None of this required leaving Rails. The framework wasn't the bottleneck — our assumptions about how requests actually flowed through it were.
What didn't help
Two things we tried and abandoned: caching everything aggressively (it just moved the invalidation bugs somewhere harder to see), and splitting into microservices before we understood our actual bottleneck. Both are popular defaults. Neither was the fix here.
The lesson that stuck: scale the thing you've measured, not the thing that sounds impressive in a postmortem.