Back to Writing
09 — Engineering

Scaling Ruby on Rails Past 10 Million Requests a Day

Cyrus Kiprop·March 14, 2026·2 min read

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:

ruby
# 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.