<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Dane Wu</title>
    <description>The latest articles on DEV Community by Dane Wu (@danewu).</description>
    <link>https://dev.to/danewu</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F119530%2F80d02717-02cc-4a44-8378-410ce66cffc4.jpeg</url>
      <title>DEV Community: Dane Wu</title>
      <link>https://dev.to/danewu</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/danewu"/>
    <language>en</language>
    <item>
      <title>Diagnosing a slow Rails page, layer by layer</title>
      <dc:creator>Dane Wu</dc:creator>
      <pubDate>Sun, 21 Jun 2026 11:09:13 +0000</pubDate>
      <link>https://dev.to/danewu/diagnosing-a-slow-rails-page-layer-by-layer-3abo</link>
      <guid>https://dev.to/danewu/diagnosing-a-slow-rails-page-layer-by-layer-3abo</guid>
      <description>&lt;p&gt;"This page feels slow" is a vague bug report. Before changing any code, it helps to&lt;br&gt;
have a fixed way to locate &lt;em&gt;where&lt;/em&gt; the time goes. A Rails request passes through a few&lt;br&gt;
predictable layers, and each layer has its own tools and its own typical failure mode.&lt;/p&gt;

&lt;p&gt;Here is the mental model I use, and a real example of walking it end to end.&lt;/p&gt;
&lt;h2&gt;
  
  
  The layers of a request
&lt;/h2&gt;


&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Middleware → Controller → SQL → View → external calls → browser
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Most slowness lives in one of these:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Typical problem&lt;/th&gt;
&lt;th&gt;How it shows up&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Controller&lt;/td&gt;
&lt;td&gt;heavy logic in the request&lt;/td&gt;
&lt;td&gt;large "Executing" time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQL (count)&lt;/td&gt;
&lt;td&gt;N+1 — many tiny repeated queries&lt;/td&gt;
&lt;td&gt;query count explodes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SQL (single)&lt;/td&gt;
&lt;td&gt;a slow query, usually a missing index&lt;/td&gt;
&lt;td&gt;one query dominates&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;View&lt;/td&gt;
&lt;td&gt;rendering logic, or N+1 hiding in the template&lt;/td&gt;
&lt;td&gt;large "Rendering" time&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;External&lt;/td&gt;
&lt;td&gt;a synchronous API/email call&lt;/td&gt;
&lt;td&gt;a gap that isn't SQL&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browser&lt;/td&gt;
&lt;td&gt;large images, heavy JS&lt;/td&gt;
&lt;td&gt;backend fast, page still slow&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The point is not to guess. It's to read the numbers and let them point at the layer.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 1 — read the numbers (development)
&lt;/h2&gt;

&lt;p&gt;In development I lean on two tools:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;rack-mini-profiler&lt;/strong&gt; — a badge in the corner that breaks a request into
controller / view / SQL time, and counts every query.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;bullet&lt;/strong&gt; — watches for N+1 and tells you exactly which association to preload.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;On a storefront page that lists a page of 48 products, rack-mini-profiler showed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;Executing: stores#&lt;/span&gt;show     2 sql
&lt;span class="go"&gt;Rendering: show.html.erb   49 sql   ← 49 queries just to render?
SQL Summary:               51 sql total
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fifty-one queries to render one page of products is a red flag, and the fact that 49 of&lt;br&gt;
them happen during &lt;em&gt;rendering&lt;/em&gt; points straight at the view. bullet confirmed it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;USE&lt;/span&gt; &lt;span class="n"&gt;eager&lt;/span&gt; &lt;span class="n"&gt;loading&lt;/span&gt; &lt;span class="n"&gt;detected&lt;/span&gt;
&lt;span class="no"&gt;Product&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:image_attachment&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;span class="no"&gt;Add&lt;/span&gt; &lt;span class="n"&gt;to&lt;/span&gt; &lt;span class="n"&gt;your&lt;/span&gt; &lt;span class="ss"&gt;query: &lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="ss"&gt;:image_attachment&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Step 2 — understand the N+1
&lt;/h2&gt;

&lt;p&gt;The products use Active Storage for their images:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Product&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationRecord&lt;/span&gt;
  &lt;span class="n"&gt;has_one_attached&lt;/span&gt; &lt;span class="ss"&gt;:image&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;An attached image isn't a column on &lt;code&gt;products&lt;/code&gt;. Active Storage spreads it across three&lt;br&gt;
tables: &lt;code&gt;active_storage_attachments&lt;/code&gt; (which record owns which file),&lt;br&gt;
&lt;code&gt;active_storage_blobs&lt;/code&gt; (the file's metadata + a storage key), and&lt;br&gt;
&lt;code&gt;active_storage_variant_records&lt;/code&gt; (generated thumbnails). The file bytes themselves live in a storage service — disk locally, object storage in production.&lt;/p&gt;

&lt;p&gt;So every time the view touches &lt;code&gt;product.image&lt;/code&gt;, Rails walks those tables. In a loop over N products, that's N extra round-trips: a textbook N+1.&lt;/p&gt;
&lt;h2&gt;
  
  
  Step 3 — fix and re-measure
&lt;/h2&gt;

&lt;p&gt;The fix is to preload the attachment once, up front. Active Storage generates a scope&lt;br&gt;
for exactly this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# before&lt;/span&gt;
&lt;span class="vi"&gt;@products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"stock &amp;gt; 0"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# after&lt;/span&gt;
&lt;span class="vi"&gt;@products&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Product&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"stock &amp;gt; 0"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;with_attached_image&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;(&lt;code&gt;with_attached_image&lt;/code&gt; is just an Active Storage flavoured &lt;code&gt;includes&lt;/code&gt;.)&lt;/p&gt;

&lt;p&gt;Re-measured on the same page:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SQL queries&lt;/td&gt;
&lt;td&gt;51&lt;/td&gt;
&lt;td&gt;3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ActiveRecord time&lt;/td&gt;
&lt;td&gt;~210 ms&lt;/td&gt;
&lt;td&gt;~12 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;bullet warnings&lt;/td&gt;
&lt;td&gt;yes&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The query count is now flat regardless of how many products are on the page — O(1) instead of O(n). That's the real win: N+1 isn't scary because of its cost on any single request, it's scary because it grows with your catalog and your traffic. The same page under a few hundred requests a minute turns a handful of extra queries into thousands of extra round-trips against the database.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other kind of slow: a single heavy query
&lt;/h2&gt;

&lt;p&gt;N+1 is about query &lt;em&gt;count&lt;/em&gt;. The other common case is one query that is slow on its own — usually a missing index. Here &lt;code&gt;EXPLAIN&lt;/code&gt; is the tool: it shows how Postgres plans to run a query without running it.&lt;/p&gt;

&lt;p&gt;Looking up orders by a column with no index, over an &lt;code&gt;orders&lt;/code&gt; table with ~800k rows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="n"&gt;Seq&lt;/span&gt; &lt;span class="n"&gt;Scan&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cost&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt;&lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;21450&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;93&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;Filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'someone@example.com'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Seq Scan&lt;/code&gt; means Postgres reads the whole table row by row to find one order — wasteful when there are hundreds of thousands of them. After adding an index on that column:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;Index&lt;/span&gt; &lt;span class="n"&gt;Scan&lt;/span&gt; &lt;span class="k"&gt;using&lt;/span&gt; &lt;span class="n"&gt;index_orders_on_customer_email&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cost&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;44&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;...)&lt;/span&gt;
  &lt;span class="k"&gt;Index&lt;/span&gt; &lt;span class="n"&gt;Cond&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'someone@example.com'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Seq Scan → Index Scan&lt;/code&gt;, and the planner's cost estimate drops from ~21,000 to ~8 — the index turns "scan everything" into "jump straight to the row."&lt;/p&gt;

&lt;p&gt;Two gotchas worth knowing:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Data volume matters.&lt;/strong&gt; On a small table Postgres picks &lt;code&gt;Seq Scan&lt;/code&gt; even when an index exists — scanning a few rows is cheaper than an index lookup. The index only earns its keep once the table is large, so test against production-scale data, not a dev seed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Selectivity matters.&lt;/strong&gt; An index only helps when the query matches a small slice. A query that returns most of the table will be a &lt;code&gt;Seq Scan&lt;/code&gt; regardless.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How this plays out in production
&lt;/h2&gt;

&lt;p&gt;Development tools (rack-mini-profiler, bullet) catch problems &lt;em&gt;before&lt;/em&gt; they ship. But dev never fully mirrors production — you don't hit every page, your data is small, and some N+1s only appear with real data shapes. So production needs an APM (I use Scout) watching real traffic to catch what slipped through.&lt;/p&gt;

&lt;p&gt;The end-to-end flow when something is slow in production:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;APM flags a slow endpoint
  → reproduce locally with realistic data
  → EXPLAIN the suspect query
  → add the index / preload / cache
  → deploy, confirm in the APM that it actually got faster
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;bullet is the prevention, the APM is the evidence. They're not redundant — they're defense in depth, because dev can never be a perfect copy of prod.&lt;/p&gt;

&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Don't guess where a page is slow — read it layer by layer.&lt;/li&gt;
&lt;li&gt;Two distinct DB problems: many queries (N+1, fix with &lt;code&gt;includes&lt;/code&gt;/preload) vs one slow
query (missing index, find with &lt;code&gt;EXPLAIN&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;N+1 matters because it scales with data, not because it's slow today.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;EXPLAIN&lt;/code&gt; results depend on data volume and selectivity — test with realistic data.&lt;/li&gt;
&lt;li&gt;Prevent in development, verify in production.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>rails</category>
      <category>performance</category>
      <category>postgres</category>
      <category>database</category>
    </item>
  </channel>
</rss>
