Ruby applications slow down for predictable reasons. The same seven problems appear across nearly every Rails app we audit: excessive object allocations, N+1 queries, bad caching, slow loops, memory leaks, bloated assets, and missing profiling. Each has a well-documented fix. The challenge is diagnosing which ones affect your app and applying the right solution.
N+1 Query And Performance Optimizations | Ruby On Rails For Beginners Part 9

Problem 1: Too many object allocations
Every Ruby object consumes memory and eventually triggers garbage collection. Creating thousands of short-lived objects in a single request causes GC pauses that freeze your app.
Symptoms: High GC time in profiler output, memory spikes during requests, slow responses that improve after restart.
Fixes:
- Use symbols instead of strings for hash keys (symbols are immutable and cached)
- Modify strings in place with
String#<<instead of creating new strings with+ - Pre-allocate arrays when size is known:
Array.new(1000) - Freeze string literals with
# frozen_string_literal: trueat the top of each file - Avoid building temporary data structures inside frequently called methods
# Bad: creates a new string on every call
def full_name
first_name + " " + last_name
end
# Good: single allocation
def full_name
"#{first_name} #{last_name}".freeze
end
Problem 2: N+1 database queries
Loading a list of orders then calling order.customer on each one triggers a separate query per record. 100 orders means 101 queries.
Symptoms: Bullet gem warnings, hundreds of similar queries in logs, pages that get slower as data grows.
Fixes:
# Bad: N+1 - one query per customer
@orders = Order.all
@orders.each { |o| puts o.customer.name }
# Good: 2 queries total
@orders = Order.includes(:customer).all
| Method | When to use | Behavior |
|---|---|---|
includes | Need associated data in views | 1-2 additional queries |
joins | Filtering by association columns | Single JOIN query |
preload | Always want separate queries | Separate SELECT queries |
eager_load | Need LEFT OUTER JOIN | Single JOIN query |
Index columns used in WHERE, ORDER BY, and foreign keys. Use EXPLAIN to verify query plans before deploying.
Problem 3: Bad caching setup
Without caching, Rails re-renders views and re-runs queries on every request, even when the data has not changed.
Symptoms: Identical queries running repeatedly in logs, high database load with mostly read traffic, slow views for infrequently changing content.
Fixes:
- Fragment caching for expensive view components:
<% cache @product do %>
<%= render 'product_card', product: @product %>
<% end %>
- Low-level caching for slow computations:
Rails.cache.fetch("dashboard_stats", expires_in: 5.minutes) do
DashboardStats.compute
end
- Redis or Memcached with memory limits and eviction policies for cache storage
- Cache external API responses to avoid rate limits and latency on every request
Set appropriate expiration times. Over-caching stale data is as bad as under-caching.
Problem 4: Slow loops and iterations
Manual loops with repeated method calls inside the body waste cycles on every iteration.
Symptoms: Endpoints that scale linearly with dataset size, high CPU usage during batch operations.
Fixes:
- Use Ruby’s optimized enumerables (
map,select,reduce,each_with_object) over manual loops - Store repeated method calls in variables before the loop
- Process large datasets in batches:
# Bad: loads all records into memory
User.all.each { |u| process(u) }
# Good: loads 1000 at a time
User.find_in_batches(batch_size: 1000) do |batch|
batch.each { |u| process(u) }
end
- For CPU-intensive work, consider
Parallelgem or Ractor for true parallelism
Problem 5: Memory leaks and GC misconfiguration
Ruby’s garbage collector works well for most apps, but long-lived references, circular dependencies, and global variables prevent objects from being collected.
Symptoms: Memory usage that grows over time without flattening, OOM kills in production, slow responses after hours of uptime.
Fixes:
- Set large objects to
nilafter processing to allow GC collection - Avoid storing data in class variables or global hashes that grow unbounded
- Break circular references explicitly
- Tune GC with environment variables:
RUBY_GC_HEAP_INIT_SLOTS=600000
RUBY_GC_HEAP_GROWTH_FACTOR=1.1
RUBY_GC_HEAP_FREE_SLOTS_MIN_RATIO=0.20
- Use
jemallocas an alternative memory allocator for better performance under high allocation rates - Monitor with
MemoryProfilergem to find leak sources
Problem 6: Bloated frontend assets
Uncompressed CSS, JavaScript, and images add seconds to page load times and waste bandwidth.
Symptoms: Large asset file sizes in network tab, slow initial page loads, poor Lighthouse scores.
Fixes:
- Minify CSS and JavaScript through the asset pipeline or esbuild
- Enable gzip/brotli compression on your web server
- Serve static assets through a CDN (CloudFront, Cloudflare)
- Optimize images: use WebP format, implement responsive
srcset, lazy-load below-the-fold images - Audit bundle size with tools like
webpack-bundle-analyzer
Problem 7: No profiling or benchmarking
Without measurement, you are guessing which code is slow. Guesses are usually wrong.
Symptoms: “It feels slow” reports without data, optimizing code that does not matter, missing the real bottleneck.
Tools for profiling:
- Rack Mini Profiler: Shows per-request timing breakdown (DB queries, view rendering, total time) as a badge in the browser
- Bullet gem: Detects N+1 queries and unused eager loads during development
- APM tools (New Relic, Scout, Skylight): Production monitoring with request tracing, slow query detection, and memory tracking
- Ruby Benchmark module: Compare implementations side by side:
require 'benchmark'
Benchmark.bm do |x|
x.report("map:") { (1..100_000).map { |i| i * 2 } }
x.report("each:") { result = []; (1..100_000).each { |i| result << i * 2 } }
end
Profile first. Optimize the slowest thing. Measure again. Repeat.
Practical Implementation: The USEO Approach
At USEO, we run performance audits on Ruby on Rails applications as a standard engagement. Here is our process and what we consistently find:
The 80/20 rule applies every time. In every audit, 2-3 endpoints account for 80% of response time. We start by instrumenting all endpoints with APM, then focus exclusively on the worst offenders. Fixing three slow endpoints often improves overall p95 latency by 50% or more.
N+1 queries are the single biggest issue. Across 30+ Rails audits, N+1 queries have been the primary performance problem in over 70% of cases. We enforce the Bullet gem in CI: if Bullet detects an N+1 in any test, the build fails. This prevents regression after fixes are applied.
Memory profiling catches hidden costs. We run derailed_benchmarks on every production app to measure memory consumption per gem. It is common to find gems that add 50-100MB of memory usage for features that could be implemented in 20 lines of code. Removing or replacing bloated gems has cut memory usage by 40% in multiple projects.
jemalloc is a free performance win. Switching from Ruby’s default allocator to jemalloc typically reduces memory usage by 10-30% with zero code changes. We include it in every production Dockerfile:
RUN apt-get install -y libjemalloc2
ENV LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2
Caching strategy depends on traffic patterns. For apps with high read-to-write ratios (content sites, dashboards), aggressive fragment caching with Russian doll invalidation delivers the best results. For apps with frequent writes (collaboration tools, messaging), we focus on query optimization and avoid caching that would be invalidated immediately.
Background jobs for anything over 200ms. Any operation that takes more than 200ms in a web request gets moved to a Sidekiq job. This includes email sending, PDF generation, external API calls, and report compilation. The user gets an instant response, and the heavy work happens asynchronously.
FAQs
How do you detect N+1 queries in a Rails application?
Install the Bullet gem and enable it in development and test environments. Bullet monitors every request and logs warnings when it detects N+1 queries or unused eager loads. For production, APM tools like New Relic flag endpoints with high query counts. Fix N+1s by adding includes, preload, or eager_load to the relevant Active Record queries.
How do you optimize Ruby memory usage?
Start with MemoryProfiler to identify which objects consume the most memory. Enable frozen_string_literal: true in all files to prevent string duplication. Use jemalloc as your memory allocator. Tune GC settings based on your application’s allocation patterns. Remove or replace gems that consume disproportionate memory relative to their functionality.
Which profiling tools should you use?
Use Rack Mini Profiler for per-request timing in development. Use the Bullet gem specifically for N+1 detection. Use benchmark-ips for comparing code implementations. In production, use an APM tool (New Relic, Scout, or Skylight) for continuous monitoring. Run derailed_benchmarks periodically to track memory usage per gem.