Why your Rails performance fixes don't work (and how to find ones that do)

You’ve been there. The app feels sluggish. You start optimizing—replacing where.first with find_by, caching random method calls, switching from pluck to select. After hours of work, you deploy… and nothing changes. The app is still slow.

Here’s why: you’re probably optimizing code that doesn’t matter.

The uncomfortable truth about Rails performance

In most Rails applications, the vast majority of performance issues stem from a small fraction of your codebase. This isn’t speculation—it’s a pattern that emerges from the fundamental mathematics of how code executes in production, following what’s known as the Pareto Principle or 80/20 rule: roughly 80% of your performance problems come from just 20% of your code.

Think about your typical Rails app:

  • Most requests hit a handful of popular endpoints
  • Those endpoints usually call the same few service objects or models
  • Within those, specific database queries or calculations dominate execution time

Yet developers often spread optimization efforts evenly across the codebase, wasting time on code that barely impacts overall performance.

Why guessing doesn’t work

Let me share a pattern I’ve seen repeatedly. A developer notices their app is slow and starts optimizing based on assumptions:

1
2
3
4
5
6
7
8
9
10
11
# "This must be slow because it's in a loop!"
users.each do |user|
  # Spend hours optimizing this
  user.calculate_something_simple
end

# Meanwhile, this innocent-looking line...
@store = Store.includes(:products => [:variants, :images])
              .where(featured: true)
              .first
# ...is loading 10,000 product variants and 30,000 images into memory

The loop might run 10 times with simple calculations. The “innocent” query might be loading your entire database into memory. Which one deserves optimization?

Without measurement, you’re shooting in the dark.

Finding the real bottlenecks

Here’s a systematic approach to identifying performance problems that actually matter:

Step 1: Measure at the highest level

Start with your APM tool (New Relic, Scout, Skylight, etc.) and look for patterns:

1
2
Which endpoints consume the most total time?
Total Time = Average Response Time × Request Volume

A 2-second endpoint hit once per day matters less than a 200ms endpoint hit 10,000 times per hour.

Step 2: Profile the hot paths

Once you’ve identified problematic endpoints, profile them locally. Here’s a simple approach using rack-mini-profiler:

1
2
3
4
5
6
# Gemfile
group :development do
  gem 'rack-mini-profiler'
  gem 'flamegraph'
  gem 'stackprof'
end

Now hit your endpoint with ?pp=flamegraph appended to see exactly where time goes.

The flamegraph will reveal the truth through its visual stack trace: wide bars representing methods that consume the most time, with their child method calls stacked below. You might discover:

  • That innocent includes query spans 60% of the flamegraph width
  • Multiple database round trips hidden in serializers
  • Unexpected N+1 queries from lazy-loaded associations
  • Heavy computation in methods you thought were trivial

The wider the bar, the more time that method consumes. Deep stacks reveal complex call chains that might be optimization opportunities.

Step 3: Validate with production data

Local profiling uses development data. Before optimizing, validate against production patterns:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# Simple production sampling
class ApplicationController < ActionController::Base
  around_action :sample_performance
  
  private
  
  def sample_performance
    return yield unless rand(100) == 1 # Sample 1% of requests
    
    result = nil
    time = Benchmark.realtime { result = yield }
    
    if time > 0.5 # Log slow requests
      Rails.logger.info "[SLOW] #{controller_name}##{action_name}: #{time}s"
      # Log additional context like user_id, params, etc.
    end
    
    result
  end
end

The measurement-first workflow

Here’s the workflow that actually works:

  1. Identify slow endpoints using production metrics
  2. Profile those specific endpoints to find bottlenecks
  3. Measure the impact of potential optimizations
  4. Implement only changes with meaningful impact
  5. Verify improvement in production

Let’s see this in action:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# You profile and find this query taking 800ms:
def dashboard_data
  @projects = current_user.projects
                         .includes(:tasks, :members)
                         .where('created_at > ?', 1.year.ago)
end

# Hypothesis: It's loading too much data
# Measurement: How many records are we actually loading?
Rails.logger.info "Loading #{@projects.count} projects"
Rails.logger.info "Total tasks: #{@projects.sum { |p| p.tasks.size }}"
# Result: 50 projects, 15,000 tasks!

# Solution: Don't load everything
def dashboard_data
  @projects = current_user.projects
                         .where('created_at > ?', 1.year.ago)
                         .select(:id, :name, :status)
  
  # Load counts separately
  @task_counts = Task.where(project_id: @projects.pluck(:id))
                     .group(:project_id)
                     .count
end
# Result: 50ms instead of 800ms

Common bottleneck patterns in Rails

Through measurement, you’ll often find these patterns:

1. N+1 queries in serializers

1
2
3
4
5
6
7
# The profiler shows hundreds of identical queries
render json: @posts, each_serializer: PostSerializer

# Inside PostSerializer
def author_name
  object.author.name # N+1!
end

2. Loading unnecessary data

1
2
3
4
5
# Profiler shows massive memory allocation
User.where(active: true) # Loading all columns for 10,000 users

# When you only need:
User.where(active: true).pluck(:id, :email)

3. Missing database indexes

1
2
3
4
5
6
7
# Profiler shows long database time
Order.where(user_id: params[:user_id], status: 'pending')

# Check your query plan:
Order.where(user_id: 1, status: 'pending').explain
# => Seq Scan on orders (cost=0.00..1834.00 rows=1 width=32)
#    No index!

Tools for measurement-driven optimization

Instead of guessing, use these tools:

For production monitoring:

  • APM tools (New Relic, Scout, Skylight, Datadog)
  • Custom logging and metrics
  • Database slow query logs

For local profiling:

  • rack-mini-profiler - Real-time web UI
  • ruby-prof - Detailed method-level profiling
  • memory_profiler - Find memory bottlenecks
  • benchmark-ips - Compare implementation options

For database analysis:

  • explain on ActiveRecord queries
  • pg_stat_statements for PostgreSQL
  • Query visualization tools

The optimization decision framework

Before optimizing anything, ask:

  1. Is this code in the critical path? If profiling shows it’s consuming <5% of request time, move on.

  2. What’s the potential impact? If you could make it 10x faster, would users notice?

  3. What’s the implementation cost? A complex caching layer for a 10ms improvement rarely makes sense.

  4. Can you measure the improvement? If you can’t measure it, you can’t improve it.

Conclusion

Stop optimizing code based on hunches. The vast majority of performance improvements come from fixing a tiny fraction of your codebase—but only if you identify the right fraction.

Measure first. Profile second. Optimize third. This order matters.

The next time your Rails app feels slow, resist the urge to start optimizing random code. Instead, check your metrics to identify problem areas, then profile those specific endpoints to find out where the time actually goes. You’ll be surprised how often the real bottleneck isn’t where you expected.

Prateek Choudhary
Prateek Choudhary
Technology Leader