Ruby on Rails Performance Improvements

·14 min read·Programming Languagesintermediate

Why performance tuning is crucial for modern Rails applications and how recent updates help

A server rack containing web application infrastructure, symbolizing the hardware and architecture underpinning Ruby on Rails deployments

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 concurrent requests early.
  • Boot time is a productivity killer. bootsnap saves minutes per day during development and redeployments.
  • HTTP caching is underused. A good ETag strategy 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 systemd or 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

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.