• Home
  • About
  • Portfolio
  • Contact
CodeCurious
  • Home
  • About
  • Portfolio
  • Contact
Go Back

10 Common Ruby On Rails Performance Bottlenecks

Discover 10 common Ruby on Rails performance bottlenecks and learn practical ways to speed up your application.

Jean Emmanuel Cadet
By Jean Emmanuel Cadet • Ruby on Rails Developer

Last updated : Jul 15, 2026 • 18 min read

10 Common Ruby on Rails Performance Bottlenecks

Last updated : Jul 15, 2026 • 18 min read

Share with friends

Ruby on Rails has a reputation problem. Ask around in developer circles, and someone will eventually say "Rails doesn't scale" or "Ruby is too slow for real production apps." The truth is more boring and more useful: most slow Rails applications are not slow because of Rails. They are slow because of how the application queries the database, renders views, and handles background work.

Companies like Shopify, GitHub, and Basecamp run enormous amounts of traffic on Rails every single day. If the framework itself were the bottleneck, none of that would be possible. What actually happens is that as an application grows, small inefficiencies pile up. A missing index here, an N+1 query there, a callback that does too much work, and suddenly a page that used to load in 100 milliseconds takes three full seconds.

The good news is that Rails performance problems tend to follow predictable patterns. Once you know what to look for, you can usually diagnose and fix them in an afternoon rather than a week. This article walks through ten of the most common Rails performance bottlenecks, how to spot them, and how to fix them using Rails 8 conventions and tools that are either built into Rails or are the de facto standard in the community.

By the end of this article, you will know how to measure performance before touching any code, how to eliminate N+1 queries, when and how to use fragment caching, how to design better indexes, and how to avoid the traps that quietly slow down even well-written Rails applications.


How to Diagnose a Slow Rails Application

Before you optimize anything, you need to know where the time is actually going. Guessing is the single most common mistake developers make when chasing performance issues. You might spend hours optimizing a query that only accounts for two percent of total response time while a slow partial render eats up sixty percent.

Measure Before Optimizing

Treat performance work like a scientific process. Establish a baseline, form a hypothesis about what is slow, make one change, and measure again. If you change five things at once, you will not know which change actually mattered.

Using Rails Logs

The Rails development log is the first place to look. Every request logs how long it took, how much time was spent in the database versus rendering views, and which queries ran. A log entry like this tells you a lot:

Completed 200 OK in 842ms (Views: 210ms | ActiveRecord: 610ms | Allocations: 45213)

Here, ActiveRecord time dominates the request. That is a strong signal to look at your queries before touching your views.

Rack Mini Profiler

The rack-mini-profiler gem adds a small performance badge to every page in development, showing SQL time, view rendering time, and a breakdown of individual queries. It is one of the fastest ways to spot a page doing far more database work than it should.

# Gemfile
group :development do
gem "rack-mini-profiler"
end

Once installed, every page shows a small millisecond counter in the corner. Clicking it expands into a full breakdown of SQL calls, including duplicate queries, which is often the first clue that N+1 queries are happening.

Bullet

The bullet gem is purpose-built to detect N+1 queries and unused eager loading. It watches your requests in development and warns you, either in the browser, the log, or as a JavaScript alert, whenever it detects inefficient query patterns.

# config/environments/development.rb
config.after_initialize do
Bullet.enable = true
Bullet.alert = true
Bullet.bullet_logger = true
Bullet.console = true
end

Bullet is one of those tools that pays for itself the first time it catches a problem you did not know existed.

ActiveSupport Notifications

For deeper visibility, Rails ships with an instrumentation system called ActiveSupport::Notifications. You can subscribe to events like sql.active_record or render_partial.action_view to build custom performance dashboards or simply log slow operations.

ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
event = ActiveSupport::Notifications::Event.new(*args)
Rails.logger.warn("Slow query (#{event.duration}ms): #{event.payload[:sql]}") if event.duration > 100
end

Browser DevTools

Not every performance problem lives on the server. Slow asset loading, unoptimized images, and render-blocking JavaScript can make a fast Rails backend feel sluggish. The Network and Performance tabs in Chrome DevTools will show you exactly where time goes on the frontend.

Database Query Logs

Finally, your database has its own tools. PostgreSQL's EXPLAIN ANALYZE shows you exactly how a query is executed, whether it uses an index, and where the planner spends its time. This becomes essential once you move past basic N+1 fixes and start optimizing individual queries.

EXPLAIN ANALYZE SELECT * FROM articles WHERE author_id = 42;

With these tools in your belt, you are ready to start fixing the actual bottlenecks.


Bottleneck #1: N+1 Queries

N+1 queries are the most famous Rails performance problem, and for good reason. They are easy to introduce and easy to miss during development because everything works correctly, just slowly.

The Problem

An N+1 query happens when you load a collection of records and then trigger a separate database query for each one to load an association.

articles = Article.all

articles.each do |article|
puts article.author.name
end

If there are 50 articles, this code runs 1 query to fetch the articles and then 50 additional queries, one for each author. That is 51 queries where 2 would have been enough.

Fixing It with includes, preload, and eager_load

Rails gives you three tools to solve this, and choosing the right one matters. includes lets Rails decide between a separate query and a single joined query based on your where clauses. preload always issues separate queries. eager_load always uses a LEFT OUTER JOIN.

articles = Article.includes(:author)

articles.each do |article|
puts article.author.name
end

Now Rails runs just 2 queries total, regardless of how many articles there are.

If you want a full breakdown of when to reach for each strategy, this guide covers it in detail: includes vs preload vs eager_load in Ruby on Rails.

A Real-World Example

Fixing N+1 queries in a production application is rarely as clean as the textbook example above, especially once nested associations and conditional loading get involved. If you want to see a step-by-step walkthrough of hunting down and fixing N+1 queries in a real codebase, this article is worth reading: Fix N+1 Queries in Rails: A Developer's Journey to Speed.

Performance Impact

On a page listing 100 articles with authors, categories, and tags, an unoptimized view can easily trigger 300 or more queries. With proper eager loading, that same page can run in under 10 queries. In practice, this often means the difference between a 1.5-second page load and a 90-millisecond one.


Bottleneck #2: Missing Fragment Caching

Even with perfectly optimized queries, rendering views takes real work. Partials that loop over collections, format dates, or build complex HTML structures can become surprisingly expensive at scale.

Repeated View Rendering

If your homepage renders the same article card partial 20 times, and each render does formatting, association lookups, and helper calls, that adds up fast, especially under load.

Fragment Caching in Practice

Rails makes fragment caching almost free to add:

<% @articles.each do |article| %>
<% cache article do %>
<%= render "article_card", article: article %>
<% end %>
<% end %>

The first time this renders, Rails stores the HTML output in the cache store, keyed by the article and its updated_at timestamp. On subsequent requests, Rails skips rendering entirely and serves the cached HTML.

Before and After

Without caching, rendering 20 article cards might take 180ms. With warm fragment caches, the same view can render in under 15ms, because Rails is reading pre-built HTML instead of re-executing view logic.

Cache Invalidation

Rails handles the hardest part of caching, invalidation, automatically through key-based expiration. Because the cache key includes updated_at, any change to the record generates a new cache key, and the old entry simply becomes unused rather than needing to be manually cleared.

<% cache [article, article.comments.maximum(:updated_at)] do %>
<%= render "article_with_comments", article: article %>
<% end %>

This pattern, called Russian doll caching, ensures that a new comment invalidates the outer cache without you writing any manual expiration logic.

For a deeper dive into cache stores, nested caching, and common pitfalls, this guide covers it thoroughly: How Fragment Caching Works in Ruby on Rails.


Bottleneck #3: Missing Database Indexes

Indexes are one of the highest leverage performance tools available, and they live entirely outside your Ruby code.

What Indexes Actually Do

Without an index, the database has to scan every row in a table to find matches for a query. With an index, it can jump directly to the relevant rows, similar to how a book's index lets you skip straight to a page instead of reading cover to cover.

Adding an Index

If you frequently query articles by author, an index on author_id is essential:

class AddIndexToArticlesAuthorId < ActiveRecord::Migration[8.0]
def change
add_index :articles, :author_id
end
end

For columns used together in queries, composite indexes often help more than separate single-column indexes:

add_index :articles, [:author_id, :published_at]

Measuring the Improvement

On a table with 500,000 rows, a query filtering by author_id without an index might take 400ms. The same query with an index typically drops to under 5ms. That is not a marginal improvement; it is a different order of magnitude entirely.

-- Before index: Seq Scan on articles (cost=0.00..12500.00 rows=50 width=120)
-- After index: Index Scan using index_articles_on_author_id (cost=0.29..8.31 rows=50 width=120)

A good rule of thumb: any column used in a WHERE, JOIN, or ORDER BY clause on a large table is a strong candidate for an index. Foreign keys should almost always be indexed.


Bottleneck #4: Loading Too Much Data

A subtler bottleneck than N+1 queries is simply loading more data than you need. Active Record makes it easy to grab entire records when you only need a fragment of the information.

select

If a view only needs the title and slug of an article, there is no reason to load every column:

Article.select(:id, :title, :slug)

pluck

When you need raw values rather than full model instances, pluck skips object instantiation entirely:

Article.published.pluck(:title)

exists?

Checking for the presence of records should never load the records themselves:

# Slow
Article.where(author: author).any?

# Fast
Article.where(author: author).exists?

count

Similarly, counting records should use SQL's COUNT, not Ruby's array length on a loaded collection:

# Slow, loads every record into memory
Article.where(published: true).to_a.size

# Fast, one SQL COUNT query
Article.where(published: true).count

find_each

Iterating over large tables with .all.each loads every record into memory at once, which can crash a process on large datasets. find_each batches the work automatically:

Article.find_each(batch_size: 1000) do |article|
article.recalculate_reading_time!
end

Loading only what you need is often the cheapest performance win available, because it usually requires changing one line of code.


Bottleneck #5: Inefficient Active Record Queries

Beyond N+1 queries and missing indexes, the shape of your queries matters.

Chaining Scopes Carefully

Scopes are great for readability, but chaining too many can produce redundant JOINs or conditions that fight each other:

class Article < ApplicationRecord
scope :published, -> { where(published: true) }
scope :recent, -> { order(created_at: :desc) }
scope :by_author, ->(author) { where(author: author) }
end

Article.published.recent.by_author(current_user)

This is idiomatic Rails and performs well, as long as each scope stays narrow and focused rather than pulling in unrelated associations.

Avoiding Unnecessary Joins

Only join tables you actually filter or select from. An unnecessary joins(:comments) on a query that never references comments can silently duplicate rows if the association is a has_many, inflating both result size and query time.

Database-Friendly Querying

Push work down to the database whenever possible. Filtering, sorting, and aggregating in SQL is almost always faster than pulling records into Ruby and processing them with Enumerable methods.

# Slower, processes in Ruby
Article.all.select { |a| a.views > 1000 }

# Faster, processes in SQL
Article.where("views > ?", 1000)

Bottleneck #6: Slow Partial Rendering

Views are often overlooked as a performance bottleneck because they "just work," but rendering can be surprisingly expensive at scale.

Too Many Partials

Every partial render has overhead: file lookup, compilation, and Ruby method calls. A view that renders 200 tiny partials individually pays that overhead 200 times.

Collection Rendering

Rails has a built-in optimization for rendering collections that avoids repeated partial lookup:

<%= render partial: "article_card", collection: @articles %>

This is meaningfully faster than looping manually with each and calling render inside the loop, because Rails can batch the partial lookup and, in many cases, use spacer templates more efficiently.

Cached Partials

Combine collection rendering with fragment caching for the biggest gains:

<%= render partial: "article_card", collection: @articles, cached: true %>

The cached: true option tells Rails to use Rails.cache.read_multi, fetching all cached fragments in a single round trip instead of one lookup per item.

Rendering Optimization

If a partial performs expensive calculations, consider precomputing that data in the controller or a presenter object rather than in the view, where it is easy to accidentally repeat the work per iteration.


Bottleneck #7: Heavy Callbacks

Active Record callbacks are convenient, but they hide performance costs behind an innocent-looking method call.

before_save and after_commit

A before_save callback that makes an external API call or performs a heavy calculation runs on every single save, including ones triggered by unrelated code paths like admin tools or background jobs.

class Article < ApplicationRecord
before_save :generate_reading_time
after_commit :notify_subscribers, on: :create
end

Hidden Performance Costs

The problem is not callbacks themselves; it is callbacks that do too much or run too often. A before_save that recalculates a word count is fine. A before_save that sends an email synchronously will make every save wait on an SMTP connection.

Better Alternatives

Move slow or external work out of the request cycle entirely using background jobs:

class Article < ApplicationRecord
after_commit :enqueue_notification, on: :create

private

def enqueue_notification
ArticlePublishedNotifierJob.perform_later(id)
end
end

after_commit is also generally safer than after_save for triggering side effects, because it only runs once the database transaction has actually succeeded.


Bottleneck #8: Long-Running Background Jobs

Background jobs move slow work out of the request response cycle, but poorly designed jobs create their own bottlenecks.

Solid Queue and Active Job

Rails 8 ships with Solid Queue as the default Active Job backend, backed by the database instead of requiring a separate Redis process. This makes background job infrastructure simpler to deploy while still supporting retries, concurrency controls, and recurring jobs.

class ImageProcessingJob < ApplicationJob
queue_as :default

def perform(image_id)
image = Image.find(image_id)
image.generate_variants!
end
end

Breaking Large Jobs into Smaller Jobs

A job that processes 10,000 records in a single perform method can run for a very long time and will restart from scratch on failure. Splitting work into smaller, independently retryable jobs is more resilient:

class BulkImportJob < ApplicationJob
def perform(import_id)
import = Import.find(import_id)
import.record_ids.each_slice(100) do |batch|
ProcessImportBatchJob.perform_later(batch)
end
end
end

Monitoring Job Performance

Track job duration and failure rates in production. A job that used to take 2 seconds and now takes 30 is often an early warning sign of a query or external API becoming slower, well before users notice anything in the interface itself.


Bottleneck #9: Asset and Frontend Performance

Backend optimization only gets you halfway. If images are unoptimized or JavaScript blocks rendering, users still experience a slow application.

Image Optimization

Serve appropriately sized, modern-format images using Active Storage variants:

image.variant(resize_to_limit: [800, 800]).processed

Converting to WebP typically reduces file size by 25 to 35 percent compared to JPEG at equivalent visual quality, which directly improves load time on image-heavy pages.

Lazy Loading

Native lazy loading defers offscreen images until the user scrolls near them:

<%= image_tag article.cover_image, loading: "lazy" %>

Importmap or JavaScript Optimization

Rails 8's default Importmap setup avoids a Node build step entirely, but it is still worth auditing which JavaScript actually ships to the browser. Unused Stimulus controllers or libraries add weight without adding value.

CSS Optimization

Avoid shipping unused CSS. If you are using Tailwind, its build process already purges unused classes in production, but custom stylesheets can accumulate dead rules over time that are worth periodically auditing.

HTTP Caching

Set proper cache headers on static assets so returning visitors do not re-download unchanged files:

config.public_file_server.headers = {
"Cache-Control" => "public, max-age=31536000"
}

Bottleneck #10: Poor Database Design

Some performance problems cannot be fixed with code changes alone because they are baked into the schema itself.

Missing Foreign Keys

Foreign keys enforce referential integrity at the database level and, combined with indexes, keep joins fast:

add_reference :articles, :author, foreign_key: true, index: true

Inefficient Relationships

A has_many :through relationship that requires three joins to answer a simple question is a sign that the schema may need rethinking, especially if that query runs on every page load.

Large Tables

Tables with tens of millions of rows behave very differently than tables with thousands. Techniques like partitioning, archiving old data, or denormalizing frequently accessed fields become necessary at that scale, even though they would be premature optimization on a smaller table.

Normalization and Schema Design

Normalization prevents data duplication and inconsistency, but over-normalizing can force expensive joins for common queries. Good schema design is a balance, informed by how the data is actually queried, not just how it is conceptually related.


Performance Optimization Checklist

Before deploying a feature to production, run through this checklist:

  • Run Bullet in development and resolve any N+1 warnings
  • Check Rack Mini Profiler for any single query over 50ms
  • Confirm foreign key columns have indexes
  • Add fragment caching to any partial rendered in a loop
  • Replace .all.each with find_each on large tables
  • Use pluck, exists?, and count instead of loading full records unnecessarily
  • Move slow or external work into background jobs
  • Verify images are optimized and lazy-loaded where appropriate
  • Run EXPLAIN ANALYZE on any query touching a table over 100,000 rows
  • Load test critical pages before a major release

Common Performance Myths

"Rails is slow." Ruby is not the fastest language available, but for the vast majority of applications, the database and view rendering account for far more response time than the Ruby interpreter itself.

"Caching solves everything." Caching hides slow code; it does not fix it. A poorly optimized query wrapped in a cache will still be slow the first time it runs, and slow again the moment the cache expires.

"More servers always fix performance." Horizontal scaling helps with throughput under load, but it does nothing to address a single slow request caused by an N+1 query or a missing index. Scaling infrastructure to compensate for inefficient code is expensive and temporary.

"PostgreSQL automatically makes Rails faster." A well-configured database helps, but a well-indexed SQLite database will outperform a poorly indexed PostgreSQL database. The database engine matters less than how you use it.


Real-World Case Study

Consider a sample blog application with an articles index page showing 25 articles, each with an author, a category, and a comment count.

Baseline: The unoptimized page loads in 1,850ms and runs 187 database queries. Rack Mini Profiler shows most of the time split between repeated author lookups and repeated comment count queries.

Step 1, fix N+1 queries:

@articles = Article.includes(:author, :category).published.recent

Query count drops from 187 to 6. Load time drops to 420ms.

Step 2, add a counter cache for comments instead of running article.comments.count in the view:

add_column :articles, :comments_count, :integer, default: 0

Load time drops to 210ms.

Step 3, add fragment caching around each article card:

<% cache article do %>
<%= render "article_card", article: article %>
<% end %>

On a warm cache, load time drops to 45ms.

Step 4, add an index on articles.published_at used in the recent scope's ordering. Even with caching, the initial cold query benefits, dropping from 60ms to 4ms for that specific query.

Final result: a page that started at 1,850ms with 187 queries now loads in roughly 45ms warm and under 200ms cold, using 6 queries total. None of these changes required rewriting the application, only correcting how data was fetched and rendered.


Frequently Asked Questions

Why is my Rails application slow? Most slow Rails applications suffer from N+1 queries, missing indexes, or uncached view rendering rather than any inherent limitation of the framework. Start by measuring, not guessing.

How do I profile a Rails application? Use Rack Mini Profiler for per-request breakdowns, Bullet for N+1 detection, and your database's EXPLAIN ANALYZE for individual query analysis. Combined, these three tools cover the vast majority of performance issues.

What is the biggest Rails performance issue? N+1 queries are the most common issue developers encounter, largely because they are invisible in code review and only show up under realistic data volumes.

Should I cache everything? No. Cache expensive, frequently accessed, and infrequently changing data first. Caching cheap operations adds complexity without meaningful benefit, and aggressive caching can introduce stale data bugs if invalidation is not handled carefully.

How do I improve database performance? Add indexes on columns used in WHERE, JOIN, and ORDER BY clauses, avoid loading unnecessary columns, and regularly review slow query logs in production.

Which gem helps detect N+1 queries? The Bullet gem is the standard tool for this in the Rails community. It monitors requests in development and automatically flags inefficient association loading.


Conclusion

Rails performance problems almost always come down to the same handful of causes: N+1 queries, missing indexes, uncached view rendering, loading more data than necessary, and background work that should not be running synchronously. None of these are exotic problems, and none of them requires rewriting your application in a different framework.

The real skill is not memorizing every optimization technique; it is building the habit of measuring before optimizing. Profile first, form a hypothesis, make one change, and measure again. Fix the bottleneck that is actually costing you the most time, not the one that looks the most interesting to fix.

Optimize incrementally rather than prematurely. A fast application is not built by obsessing over every query on day one; it is built by paying attention as the application grows and addressing bottlenecks as they actually appear.

💌 Don’t miss out! Join my newsletter for web development tips, tutorials, and insights delivered straight to your inbox.

Thanks for reading & Happy coding! 🚀

Follow me on:

Code. Learn. Grow.

A friendly newsletter sharing dev tips, lessons, and wins from my journey.

    Enter valid email address

    Services Tailored to Your Needs


    coding

    Web & Mobile Development

    Custom websites and mobile apps built to be fast, modern, and user-friendly. From sleek landing pages to full-scale applications, I deliver solutions that engage your audience and grow your business.

    API development

    Seamlessly connect your systems with secure, scalable APIs. I design and integrate APIs that improve efficiency, reliability, and flexibility for your business processes.

    Database design and management

    Reliable database solutions tailored to your needs. I design, optimize, and maintain databases that ensure performance, security, and scalability for your applications.

    You might also like…

    How Active Record Associations Work in Ruby on Rails
    Web Development

    How Active Record Associations Work In Ruby On Rails

    By Jean Emmanuel Cadet
    Published on: Jul 13, 2026
    includes vs preload vs eager_load in Ruby on Rails
    Web Development

    Includes Vs Preload Vs Eager_load In Ruby On Rails

    By Jean Emmanuel Cadet
    Published on: Jul 10, 2026
    How Fragment Caching Works in Ruby on Rails
    Ruby On Rails

    How Fragment Caching Works In Ruby On Rails

    By Jean Emmanuel Cadet
    Published on: Jul 08, 2026
    CodeCurious

    Designed for those who view software as architecture and code as literature.

    Legal

    Terms & Conditions Privacy Policy Disclaimer

    CodeCurious © 2025 - 2026. All rights reserved. | Made with ♥ by @jecode93