10 Common Ruby On Rails Performance Bottlenecks
Discover 10 common Ruby on Rails performance bottlenecks and learn practical ways to speed up your application.
• 18 min read
• 18 min read
Discover 10 common Ruby on Rails performance bottlenecks and learn practical ways to speed up your application.
• 18 min read
• 18 min read
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.
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.
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.
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.
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.
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.
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
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
Indexes are one of the highest leverage performance tools available, and they live entirely outside your Ruby code.
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.
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]
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.
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.
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)
When you need raw values rather than full model instances, pluck skips object instantiation entirely:
Article.published.pluck(:title)
Checking for the presence of records should never load the records themselves:
# Slow
Article.where(author: author).any?
# Fast
Article.where(author: author).exists?
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
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.
Beyond N+1 queries and missing indexes, the shape of your queries matters.
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.
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.
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)
Views are often overlooked as a performance bottleneck because they "just work," but rendering can be surprisingly expensive at scale.
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.
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.
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.
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.
Active Record callbacks are convenient, but they hide performance costs behind an innocent-looking method call.
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
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.
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.
Background jobs move slow work out of the request response cycle, but poorly designed jobs create their own bottlenecks.
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
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
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.
Backend optimization only gets you halfway. If images are unoptimized or JavaScript blocks rendering, users still experience a slow application.
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.
Native lazy loading defers offscreen images until the user scrolls near them:
<%= image_tag article.cover_image, loading: "lazy" %>
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.
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.
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"
}
Some performance problems cannot be fixed with code changes alone because they are baked into the schema itself.
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
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.
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 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.
Before deploying a feature to production, run through this checklist:
.all.each with find_each on large tablespluck, exists?, and count instead of loading full records unnecessarilyEXPLAIN ANALYZE on any query touching a table over 100,000 rows"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.
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.
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.
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.