A slow Rails endpoint rarely tells you what is wrong. It only tells you where a user noticed the problem.
The controller may be doing too much work. Active Record may be issuing hundreds of small queries. One query may be waiting on a lock. Redis might be healthy while a remote API consumes most of the request. Adding a cache or a larger application server before you know which case you have is guesswork.
This is the workflow we use to turn “the app feels slow” into a bounded investigation.
Write down the request you are investigating
Start with one request, not the whole application.
Record:
- route and HTTP method;
- typical and worst observed duration;
- time window and environment;
- whether the slowdown affects every request or only some inputs;
- deployment, data-volume or traffic changes near the first report;
- user-visible consequence.
“Dashboard is slow” is not enough. “GET /accounts/42/dashboard exceeded two seconds for accounts with more than 10,000 transactions after Tuesday's deploy” gives you something to reproduce.
If you do not have production traces, start with access logs and the Rails request log. Do not fill missing evidence with a theory.
Split the request into time buckets
A Rails request can wait in several places:
- before the app receives it;
- inside controller, model and view code;
- on database queries or locks;
- on cache and queue infrastructure;
- on another service;
- while rendering or transferring the response.
Your instrumentation should make those buckets visible. An application performance monitor is convenient, but it is not the only option. Rails notifications, structured logs and database logs can answer the first round of questions if they share a request or trace identifier.
The aim is not to collect every metric. It is to find the bucket that accounts for most of the observed delay.
Inspect query count and query shape separately
A request can be slow because it issues too many queries or because one query is expensive. Those are different failures.
Rails can add source locations to SQL logs in development:
# config/environments/development.rb
config.active_record.verbose_query_logs = true
That helps connect repeated SQL to the application line that triggered it. The Rails Active Record Querying guide also documents strict loading, which can expose accidental lazy loading instead of letting an association quietly issue another query:
user = User.strict_loading.first
user.comments.to_a
Do not enable unfamiliar diagnostics in production without checking their cost. Reproduce the request safely, then compare:
- total query count;
- duplicate query count;
- cumulative database time;
- the slowest individual queries;
- time spent waiting for a database connection;
- lock waits, when available.
For an expensive query, capture its real SQL and inspect the plan against representative data. A tidy query in a small development database proves very little.
Check whether the problem belongs inside the request
External HTTP calls are easy to hide in a service object. File conversion, report generation and large imports sometimes sit in a synchronous request because that was convenient during the first implementation.
List every network or CPU-heavy operation on the path. For each one, ask:
- Does the user need its result before receiving the response?
- Is there a timeout?
- Can it fail independently?
- Would moving it to a job preserve the required behavior?
Backgrounding work is not a free performance fix. It changes when failures happen and who sees them. If the request now returns before the operation finishes, the product needs an honest pending, failed and retrying state.
Treat caching as a hypothesis
Caching can remove repeated computation. It can also hide a bad query, introduce invalidation bugs and make an incident harder to understand.
Before adding a cache, define:
- the work you expect to avoid;
- cache key and ownership;
- acceptable staleness;
- invalidation rule;
- behavior on a miss or unavailable cache;
- the metric that will show whether the cache helped.
Rails supports several caching layers, from fragment caching to low-level cache entries. The right layer depends on the repeated work you measured. “Redis is fast” is not a design.
Change one important variable and measure again
Keep a small evidence table:
| Stage | p50 | p95 | Database time | Query count | External time | Notes |
|---|---|---|---|---|---|---|
| Before | ||||||
| Candidate fix | ||||||
| After rollout |
The table is intentionally blank here. Use measurements from the application you are changing. Borrowed performance numbers are not evidence.
A useful fix should improve the target metric without moving the cost somewhere worse. A faster web request that floods Sidekiq, increases stale data or overloads PostgreSQL is not finished.
Define rollout and rollback before shipping
Performance changes can alter query plans, memory use, cache pressure and job volume. Decide how you will release the change before merging it:
- feature flag or bounded cohort, if appropriate;
- health and business metrics to watch;
- database and queue thresholds;
- rollback or disable path;
- verification window;
- owner for the decision.
Then compare the same request and representative inputs after rollout. Stop once the evidence answers the decision you needed to make.
What the first diagnosis should produce
A good first pass does not need a thick report. It should leave the team with:
- a reproducible slow path;
- a breakdown of where time is spent;
- the strongest current hypothesis;
- one or more bounded interventions;
- expected trade-offs;
- a rollout and verification plan.
That is enough to choose the next step without pretending you already know the entire system.
If your team is seeing the symptom but cannot isolate the bottleneck, our diagnose-first process is designed for this kind of problem. You can also send us the system context before deciding on a larger engagement.
Sources
Checked 13 August 2026:
