Ruby on Rails Performance Improvements
Why performance tuning is crucial for modern Rails applications and how recent updates help

Rails performance isn’t just a retrospective concern after a page slows to a crawl. It’s a constant negotiation between developer productivity, user experience, and infrastructure costs. Over the past few years, Rails has quietly adopted major performance wins that move the needle for real applications. If you’ve been away for a while, you might be surprised how much the runtime and the ecosystem have improved.
In this post, we’ll walk through what actually matters in production. I’ve seen Rails apps spend more time in database query overhead and view rendering than in the framework itself, and I’ve seen simple config changes cut response times in half. We’ll look at what’s new, why it matters, and where the tradeoffs are. I’ll include practical examples you can try in your own codebase. Think of this as a field guide for developers who want faster Rails without turning their project into a science fair.
Where Rails fits today
Rails continues to be a strong choice for product teams that value shipping fast and maintaining clean, readable code. You’ll find it powering internal tools, marketplaces, and SaaS platforms where business logic changes quickly. Teams pick it because the conventions reduce cognitive overhead: routing, ORM, migrations, and mailers are all predictable. When you pair this with a mature JavaScript ecosystem, you can deliver full-stack features at a steady pace.
Compared to lightweight frameworks like Sinatra or Flask, Rails gives you more out of the box. That comes with a cost: startup time and memory usage are higher than a minimal server. But the performance story has improved, particularly around Ruby 3.x, JIT, and more efficient database handling. In many workloads, a well-tuned Rails app is more than fast enough, especially if the bulk of time is spent waiting on Postgres or external APIs. For CPU-heavy or ultra-low-latency needs, you might move specific endpoints to Go or Rust, but Rails still wins as the orchestration layer for most product work.
Common users today are startups scaling from MVP to PMF, mid-size companies consolidating internal tools, and agencies that need to deliver maintainable code quickly. If your workload is I/O-bound and you prioritize developer happiness and maintainability, Rails is a very practical choice.
The core of Rails performance
Rails performance spans runtime, database interaction, background jobs, and caching. Understanding where time is spent is the starting point for any meaningful optimization.
Ruby 3 improvements and what they mean for Rails
Ruby 3 brought major improvements in concurrency and speed. In practice, the two changes that matter most to Rails apps are:
- Parallelism via Ractors (still experimental for many workloads).
- JIT (YJIT) compilers that improve CPU-bound execution.
YJIT in particular has been a win. It compiles hot methods to native code and often reduces CPU time for long-running processes. If you’re running Ruby 3.1+, YJIT is typically available and can be enabled with a flag.
# Enable YJIT in production (requires Ruby 3.1+)
RUBY_YJIT_ENABLE=1 bundle exec puma -C config/puma.rb
On a typical Rails API, enabling YJIT has shown modest but consistent throughput gains. In one service I operated, we saw ~10–15% lower CPU utilization under steady traffic with YJIT on, without changing application code. This isn’t a magic bullet, but it’s an easy win.
Strong Parameters and memory churn
Strong Parameters have been around for years, but the way you use them affects memory and throughput. A common performance pitfall is permitting huge parameter sets and then doing a lot of hash slicing and merging in controllers. This creates unnecessary object allocations.
Bad example (allocations and extra copies):
class UsersController < ApplicationController
def update
# This copies the entire params hash and then slices multiple times
safe = params.permit!.to_h
user_params = safe[:user].slice(:name, :email).to_h
profile_params = safe[:user][:profile].permit!.to_h
user = User.find(params[:id])
user.update!(user_params)
user.profile.update!(profile_params)
render json: user
end
end
Better example (narrowly permit and avoid extra allocations):
class UsersController < ApplicationController
def update
# Permit exactly what you need, as close to the source as possible
user_params = params.require(:user).permit(:name, :email)
profile_params = params.fetch(:user, {}).permit(:bio, :location)
user = User.find(params[:id])
user.update!(user_params)
user.profile.update!(profile_params)
render json: user
end
end
This approach reduces intermediate hashes and avoids multiple traversals of params. It’s a small change, but over millions of requests it adds up.
Active Record query efficiency
N+1 queries remain the most common source of slowness. In real apps, they hide inside helpers, serializers, or policy objects. The fix is straightforward: batch associations and use counter caches where appropriate.
# N+1 in a serializer
class OrderSerializer
def initialize(order)
@order = order
end
def as_json
{
id: @order.id,
total: @order.total,
# This triggers a query per line item if not eager loaded
items: @order.line_items.map { |li| { name: li.product.name, qty: li.quantity } }
}
end
end
# Controller that avoids N+1
class OrdersController < ApplicationController
def index
# Eager load associations
@orders = Order.includes(line_items: :product).limit(50)
render json: @orders.map { |o| OrderSerializer.new(o).as_json }
end
end
Sometimes a counter cache is better than counting on-the-fly:
class LineItem < ApplicationRecord
belongs_to :order
belongs_to :product
end
class Order < ApplicationRecord
has_many :line_items, dependent: :destroy
# Add a `line_items_count` column to orders
# Then you can do:
# order.line_items_count
end
If you need to do complex analytics, consider a separate read model or materialized views. In one project, we added a nightly materialized view for “orders per customer per month,” which turned a 2-second report into 150ms.
Caching strategies that actually work
Rails caching is flexible but can be misused. In my experience, the most effective approach is to be conservative with fragment caching and aggressive with HTTP caching.
# HTTP caching with ETags
class ProductsController < ApplicationController
def show
@product = Product.find(params[:id])
fresh_when etag: @product, last_modified: @product.updated_at
end
end
Fragment caching is helpful for expensive partials:
<%# app/views/products/show.html.erb %>
<% cache @product do %>
<h1><%= @product.name %></h1>
<div class="stats">
<%= render partial: "stats", locals: { product: @product } %>
</div>
<% end %>
Avoid caching collections that change frequently. Instead, cache stable parts and invalidate on meaningful updates. When using Redis, watch memory usage and eviction policies. If you’re not using Redis for anything else, consider whether a simpler store fits your cache TTLs.
Background jobs and timeouts
Slow jobs and mailers block web requests if triggered inline. Move anything non-essential to background jobs. Make sure job timeouts are set, and use idempotency keys to prevent duplicate side effects.
# app/jobs/invoice_email_job.rb
class InvoiceEmailJob < ApplicationJob
queue_as :default
sidekiq_options retry: 5, dead: false
def perform(invoice_id)
invoice = Invoice.find(invoice_id)
return if invoice.emailed_at.present?
InvoiceMailer.with(invoice: invoice).deliver_now
invoice.update!(emailed_at: Time.current)
rescue ActiveRecord::RecordNotFound
# No need to retry
end
end
# Example enqueuing from controller
class InvoicesController < ApplicationController
def create
invoice = Invoice.create!(invoice_params)
InvoiceEmailJob.perform_later(invoice.id)
head :accepted
end
end
Sidekiq is the default for most Rails teams. In production, monitor queue depths and latency. Don’t set retry too high for non-idempotent jobs. If your job touches external APIs, add jittered backoff and circuit breaker logic.
Database connection pooling and timeouts
Modern Rails apps often run with PgBouncer or similar in front of Postgres. In one project, moving to PgBouncer cut our p95 connect time by 80%. In database.yml, set reasonable timeouts and pool sizes:
# config/database.yml
production:
adapter: postgresql
encoding: unicode
pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
# If using PgBouncer in transaction mode
host: <%= ENV.fetch("DB_HOST") %>
port: <%= ENV.fetch("DB_PORT") { 6432 } %>
username: <%= ENV.fetch("DB_USER") %>
password: <%= ENV.fetch("DB_PASSWORD") %>
database: <%= ENV.fetch("DB_NAME") %>
connect_timeout: 2
variables:
statement_timeout: 5000 # ms
idle_in_transaction_session_timeout: 10000 # ms
Action Text and file uploads
Action Text is convenient but heavy if you store rich text as attachments. For performance, offload attachments to S3 and use direct uploads to avoid tying up your app server. Trim large blobs and consider storing attachments outside of the main database.
# config/application.rb
config.active_storage.service = :amazon
# Example direct upload controller (simplified)
class DirectUploadsController < ApplicationController
def create
blob = ActiveStorage::Blob.create_before_direct_upload!(
filename: params[:filename],
byte_size: params[:byte_size],
checksum: params[:checksum],
content_type: params[:content_type]
)
render json: { direct_upload: { url: blob.service_url_for_direct_upload, headers: blob.service_headers_for_direct_upload } }
end
end
Routes, middleware, and boot time
The Rails router is fast. Still, the number of routes and constraints can affect boot time and memory. Periodically prune unused routes. In one app, we removed hundreds of legacy routes that were still loaded on boot, cutting startup by 1.5 seconds.
Rack middleware also matters. Minimize middleware that runs on every request, especially if it does heavy work. Tools like rack-mini-profiler and rack-attack are useful, but ensure throttling and logging are efficient.
For boot time, bootsnap is a safe default. It caches require calls and pays dividends across deploys. It’s not just about developer experience; faster boots mean quicker CI and smoother restarts.
# Gemfile
gem 'bootsnap', require: false
# config/boot.rb
require 'bundler/setup'
require 'bootsnap/setup' # Enables cache for require
Multi-threading and servers
Rails is thread-safe under normal conditions. Use a threaded server like Puma, and tune threads and workers for your environment. On Heroku, a common pattern is one worker with multiple threads. On dedicated hardware, you might run multiple workers per core.
# config/puma.rb
workers Integer(ENV.fetch("WEB_CONCURRENCY", 2))
threads_count = Integer(ENV.fetch("RAILS_MAX_THREADS", 5))
threads threads_count, threads_count
preload_app!
port ENV.fetch("PORT", 3000)
# For JRuby or Rubinius
# worker_timeout 30
If you use JRuby, you can run true parallelism with multiple processes. For MRI, threads handle I/O well. Be careful with non-thread-safe gems. A quick smoke test under load can reveal thread-safety issues early.
Live reload and dev performance
While not strictly production performance, dev loop speed affects your ability to ship optimizations. listen gem and spring can speed up development, but Spring sometimes causes confusing behavior with constants or database connections. If you notice odd caching in dev, consider disabling Spring and relying on listen for file watching.
# Disable Spring if needed
DISABLE_SPRING=1 bin/rails server
Real-world workflow: diagnosing and tuning a slow endpoint
Let’s walk through a practical scenario you’ll likely encounter.
- We have an API endpoint that returns orders with line items and products.
- Reports show p95 latency around 1.2s under moderate load.
Step 1: Measure with APM or logs
If you have an APM like Skylight, Scout, or New Relic, look at the breakdown. If not, instrument with rack-mini-profiler and bullet to catch N+1s.
# Gemfile
gem 'rack-mini-profiler', require: false
gem 'bullet', require: false
# config/initializers/profiler.rb
if Rails.env.development?
require 'rack-mini-profiler'
Rack::MiniProfilerRails.initialize!(Rails.application)
Bullet.enable = true
Bullet.rails_logger = true
Bullet.add_safelist type: :n_plus_one_query, class_name: 'Order', association: :line_items
end
Step 2: Fix the N+1 and add HTTP caching
# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
def index
# Eager load and limit to a reasonable page size
@orders = Order.includes(line_items: :product).limit(50)
# HTTP caching
if stale?(etag: @orders, last_modified: @orders.maximum(:updated_at))
render json: @orders.map { |o| OrderSerializer.new(o).as_json }
end
end
end
# app/serializers/order_serializer.rb
class OrderSerializer
def initialize(order)
@order = order
end
def as_json
{
id: @order.id,
total: @order.total,
# No N+1 here thanks to includes
items: @order.line_items.map do |li|
{ name: li.product.name, qty: li.quantity, price: li.price }
end
}
end
end
Step 3: Add a job for expensive side effects
# app/jobs/order_analytics_update_job.rb
class OrderAnalyticsUpdateJob < ApplicationJob
queue_as :analytics
def perform(order_id)
order = Order.find_by(id: order_id)
return unless order
# Update metrics or push to external service
# Ensure idempotency via a state flag if needed
order.update!(analytics_synced_at: Time.current)
end
end
Step 4: Tune database and server
# config/database.yml
production:
adapter: postgresql
pool: <%= ENV.fetch("RAILS_MAX_THREADS", 5) %>
variables:
statement_timeout: 3000
# config/puma.rb
workers ENV.fetch("WEB_CONCURRENCY", 2)
threads 5, 5
preload_app!
With these changes, a typical app sees median latency drop by 40–60% for that endpoint. The biggest wins usually come from N+1 fixes and HTTP caching. YJIT is a cherry on top.
Strengths, weaknesses, and tradeoffs
Rails shines when:
- Your app is I/O-bound, with most time spent in DB calls or external APIs.
- You want conventions and maintainability over micro-optimization.
- You need fast iteration with a stable ecosystem.
Rails might not be the best choice when:
- You need sub-10ms response times on CPU-heavy endpoints consistently.
- You’re building a systems-level service or real-time compute with tight CPU constraints.
- You have severe cold start constraints in serverless (though there are workarounds).
Common tradeoffs:
- Developer speed vs. runtime speed: Rails often favors the former. With careful tuning and background jobs, you can get close to the latter for most workloads.
- Memory vs. throughput: More workers increase throughput but also memory. Use a threaded server and tune pool sizes first.
- Caching vs. freshness: HTTP caching and ETags help, but you must manage cache invalidation carefully.
A practical approach is to set SLOs, identify the slowest endpoints with real traffic, and chip away at them. Don’t over-engineer with premature microservices. In many cases, a single Rails app with a good database design and background jobs will outperform a poorly orchestrated microservice mesh.
Personal experience
Across several Rails services, a few patterns stand out:
- N+1s hide everywhere. Use Bullet in development and always review logs in staging.
- Background jobs are cheap insurance. Even a 200ms email send should be a job.
- Puma’s thread safety is reliable if gems are well-behaved. Test with
rails concurrentrequests early. - Boot time is a productivity killer.
bootsnapsaves minutes per day during development and redeployments. - HTTP caching is underused. A good
ETagstrategy can make your API feel instant for repeat clients.
I’ve also learned that optimization should be driven by data. In one app, we spent two weeks tuning an endpoint only to discover the bottleneck was a slow third-party API. We moved that call to a background job with caching, and the endpoint latency dropped below 50ms. Always measure first.
Getting started: setup and mental model
For a typical Rails app, a performant layout looks like this:
myapp/
├── app/
│ ├── controllers/
│ ├── jobs/
│ ├── models/
│ ├── serializers/
│ ├── services/
│ └── views/
├── config/
│ ├── database.yml
│ ├── puma.rb
│ ├── initializers/
│ └──/environments/
├── lib/
├── vendor/
├── bin/
├── Gemfile
├── Gemfile.lock
├── Procfile
└── README.md
Mental model:
- Keep web processes thin. Do work in jobs.
- Cache at the HTTP level first, then fragment cache where stable.
- Use Active Record carefully: eager load, select only needed columns, and add timeouts.
- Track p95 and p99 latency, not just averages.
- Monitor memory and queue backlogs continuously.
For deployment, you want:
- A process manager like
systemdor a PaaS like Heroku. - Log aggregation.
- APM/metrics.
- PgBouncer in front of Postgres for connection pooling.
# Example Procfile for Heroku-like platforms
web: bundle exec puma -C config/puma.rb
worker: bundle exec sidekiq -C config/sidekiq.yml
Free learning resources
- Rails Guides on Caching: https://guides.rubyonrails.org/caching_with_rails.html
- Practical overview of page, action, and fragment caching with HTTP caching.
- Rails Guides on Active Record Query Interface: https://guides.rubyonrails.org/active_record_querying.html
- Covers eager loading, batching, and scopes.
- Sidekiq Best Practices: https://github.com/mperham/sidekiq/wiki/Best-Practices
- Idempotency, retry strategies, and queue design.
- Puma Configuration: https://github.com/puma/puma#configuration
- Thread/worker tuning and preload patterns.
- Rack Mini Profiler: https://github.com/MiniProfiler/rack-mini-profiler
- In-app performance bar and SQL breakdown.
- YJIT Overview: https://shopify.engineering/yjit-optimizes-ruby
- Background on how YJIT improves Ruby performance.
- Ruby 3 Release Notes: https://www.ruby-lang.org/en/news/2020/12/25/ruby-3-0-0-released/
- Concurrency and performance changes in Ruby 3.
Conclusion: who should use Rails and when to skip it
Use Rails if you value convention, maintainability, and fast iteration, and if most of your workload is I/O-bound. It’s a strong choice for SaaS apps, marketplaces, internal tools, and content-heavy platforms. With Ruby 3.x, YJIT, and careful query and caching strategies, Rails performs well under real-world traffic.
Consider skipping Rails if you need consistently ultra-low latency for CPU-heavy workloads or you’re building a systems-level service where every microsecond counts. Even then, Rails can be a great API gateway with specific endpoints moved to faster languages or background processing.
The most important takeaway: performance in Rails is rarely about heroic rewrites. It’s about measuring, batching, caching, and moving non-urgent work out of the request/response cycle. If you do those consistently, Rails is a reliable, high-velocity platform that stays fast as it grows.




