<?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%2Fcf523e87-f45d-4040-bc9e-d3b064757a9f.jpg</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>You've Tuned the Queries and It's Still Slow — Now Change the Data Model</title>
      <dc:creator>Dane Wu</dc:creator>
      <pubDate>Mon, 29 Jun 2026 08:58:49 +0000</pubDate>
      <link>https://dev.to/danewu/youve-tuned-the-queries-and-its-still-slow-now-change-the-data-model-41ah</link>
      <guid>https://dev.to/danewu/youve-tuned-the-queries-and-its-still-slow-now-change-the-data-model-41ah</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Rails Performance: Lessons from Production — #9&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The first eight posts optimized &lt;em&gt;without touching the schema&lt;/em&gt; — fixing N+1, adding indexes, caching, moving work to the background. But sometimes you tune the query to its limit and it's still slow — because the root cause is the &lt;strong&gt;schema design itself&lt;/strong&gt;: you normalized the data so cleanly that every query has to JOIN a pile of tables and recompute a derived value. This post takes a different axis: &lt;strong&gt;changing the data model.&lt;/strong&gt; The core idea is the denormalization trade-off. Same example throughout (&lt;code&gt;Courier has_many :shipments&lt;/code&gt;).&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  💥 Indexes are added, N+1 is fixed, the couriers page is still slow
&lt;/h2&gt;

&lt;p&gt;A couriers list page. Each row shows "how many shipments this courier has" + "the status of the latest one." You did everything right:&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="vi"&gt;@couriers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Courier&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;:shipments&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c1"&gt;# in the view: courier.shipments.count, courier.shipments.last.status&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;N+1 is fixed (&lt;code&gt;includes&lt;/code&gt;), &lt;code&gt;shipments.courier_id&lt;/code&gt; is indexed. But the page is still slow — the APM shows every row running a &lt;code&gt;COUNT&lt;/code&gt; and finding "the latest one."&lt;/p&gt;

&lt;p&gt;The thing is: &lt;strong&gt;"shipment count" and "latest status" are values that, every page load, for every courier, get recomputed from millions of shipment rows.&lt;/strong&gt; This isn't a query-style problem — your &lt;strong&gt;data model is forcing you to recompute derived values every time.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Query tricks have hit their ceiling. The next step is to change the data model: &lt;strong&gt;turn "compute every time" into "compute once and store it."&lt;/strong&gt; That's denormalization.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧮 Move 1: counter_cache — stop COUNTing the child table every time
&lt;/h2&gt;

&lt;p&gt;The most common, built-in denormalization. Store "the parent's count" on the model:&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;Shipment&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;belongs_to&lt;/span&gt; &lt;span class="ss"&gt;:courier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;counter_cache: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;   &lt;span class="c1"&gt;# adds a shipments_count column to couriers&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rails &lt;strong&gt;automatically bumps &lt;code&gt;couriers.shipments_count&lt;/code&gt; ±1 on every shipment create/destroy.&lt;/strong&gt; Reading 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="n"&gt;courier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shipments_count&lt;/span&gt;   &lt;span class="c1"&gt;# reads a column, zero COUNT queries&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;"Each courier runs its own &lt;code&gt;SELECT COUNT(*)&lt;/code&gt;" becomes "read a ready-made column."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There's one cost you must know about&lt;/strong&gt;: counter_cache &lt;strong&gt;only updates on ActiveRecord &lt;code&gt;create&lt;/code&gt; / &lt;code&gt;destroy&lt;/code&gt;.&lt;/strong&gt; Use &lt;code&gt;update_all&lt;/code&gt;, &lt;code&gt;delete_all&lt;/code&gt;, &lt;code&gt;import&lt;/code&gt;, or raw SQL and &lt;strong&gt;it won't move — the number drifts.&lt;/strong&gt; When it drifts, fix it with &lt;code&gt;Courier.reset_counters(courier.id, :shipments)&lt;/code&gt; (pass that courier's id).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Concurrency, though, you don't have to worry about — it's safe.&lt;/strong&gt; counter_cache doesn't "read it out, +1, write it back" — it lets the &lt;strong&gt;DB do the increment&lt;/strong&gt;:&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;UPDATE&lt;/span&gt; &lt;span class="n"&gt;couriers&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;shipments_count&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;shipments_count&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Why it matters: with "read then add" (&lt;code&gt;count = count + 1&lt;/code&gt; computed in Ruby), two requests each creating a shipment at the same time could &lt;strong&gt;both read 100, both compute 101, both write 101 → one increment lost.&lt;/strong&gt; But &lt;code&gt;SET count = count + 1&lt;/code&gt; is a single atomic operation in the DB; two concurrent ones get serialized (100 → 101 → 102) and nothing is miscounted.&lt;/p&gt;




&lt;h2&gt;
  
  
  📦 Move 2: store the "computed value" redundantly
&lt;/h2&gt;

&lt;p&gt;counter_cache is a special case. The general form of denormalization is: &lt;strong&gt;any "frequently needed and expensive" derived value — precompute it and store it on the parent.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;"Latest shipment status" is expensive to fetch every time (&lt;code&gt;shipments.order(:created_at).last.status&lt;/code&gt;) → store a copy on the courier:&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;# couriers gets a latest_shipment_status column&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Shipment&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;belongs_to&lt;/span&gt; &lt;span class="ss"&gt;:courier&lt;/span&gt;

  &lt;span class="c1"&gt;# after a new shipment is saved, sync its status onto the courier's redundant column&lt;/span&gt;
  &lt;span class="n"&gt;after_create_commit&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="n"&gt;courier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update_column&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:latest_shipment_status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Breaking that callback down:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;after_create_commit&lt;/code&gt;&lt;/strong&gt;: an ActiveRecord callback that fires only after a new record is created &lt;strong&gt;and the transaction commits&lt;/strong&gt; (the &lt;code&gt;_commit&lt;/code&gt; variant guarantees it's really persisted, not mid-transaction).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;courier.update_column(:latest_shipment_status, status)&lt;/code&gt;&lt;/strong&gt;: write this shipment's &lt;code&gt;status&lt;/code&gt; into the courier's &lt;code&gt;latest_shipment_status&lt;/code&gt; column (the redundant copy). &lt;code&gt;update_column&lt;/code&gt; issues a &lt;strong&gt;single SQL UPDATE on one column&lt;/strong&gt;, skipping validations/callbacks and not touching &lt;code&gt;updated_at&lt;/code&gt; — right for "just sync a redundant value."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In plain terms: &lt;strong&gt;every time a new shipment is saved, copy its status onto the courier's "latest status."&lt;/strong&gt; Reading it is just &lt;code&gt;courier.latest_shipment_status&lt;/code&gt; — one column, no JOIN, no sort.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ But &lt;strong&gt;don't rush to denormalize&lt;/strong&gt; — for &lt;code&gt;WHERE courier_id = ? ORDER BY created_at DESC LIMIT 1&lt;/code&gt;, first add a &lt;code&gt;(courier_id, created_at)&lt;/code&gt; composite index and each query becomes fast (the index is already sorted; grab one row). &lt;strong&gt;If an index solves it, don't touch the data model.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;So when is redundancy worth it? ① Even if each query is fast, a list of 50 couriers is still 50 round-trips — too many at high volume/traffic; ② "&lt;strong&gt;the latest one per group&lt;/strong&gt;" is genuinely hard to de-N+1 cleanly — &lt;code&gt;includes&lt;/code&gt; loads &lt;em&gt;all&lt;/em&gt; shipments into memory to pick from, and doing it in one SQL statement needs a window function / lateral join (not native in Rails, raw SQL). &lt;strong&gt;When the index isn't enough, or the query is too awkward to maintain, a single redundant column is the cheapest fix.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;This is the key technique for "read-heavy, hot-data" products like stock or e-commerce&lt;/strong&gt;: things like price change %, volume, total sales — expensive aggregates that must display in real time — are often stored redundantly, or even:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;materialized view&lt;/strong&gt; (Postgres &lt;code&gt;CREATE MATERIALIZED VIEW&lt;/code&gt;): "materialize" one expensive aggregate query into a physical table, refreshed on a schedule (&lt;code&gt;REFRESH&lt;/code&gt;). Good for "reports / leaderboards that are expensive but can tolerate a few minutes stale" (in Rails, the scenic gem).&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  ⚖️ The core cost of denormalization: consistency
&lt;/h2&gt;

&lt;p&gt;Denormalization isn't free — you &lt;strong&gt;stored an extra copy, and that copy can drift from the truth.&lt;/strong&gt; This is the fundamental trade-off against normalization:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Normalized&lt;/strong&gt;: data has a single source of truth, always correct, but queries JOIN/recompute (slow reads).&lt;br&gt;
&lt;strong&gt;Denormalized&lt;/strong&gt;: a redundant copy makes reads fast, but &lt;strong&gt;you're now responsible for keeping that copy in sync&lt;/strong&gt; (writes get complex, and it can be inconsistent).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;There are three ways to maintain consistency, from synchronous to asynchronous:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;callback / &lt;code&gt;touch&lt;/code&gt;&lt;/strong&gt; (synchronous): update the redundant column inline on write (the &lt;code&gt;after_create_commit&lt;/code&gt; above is exactly this). Simple, but &lt;strong&gt;easy to let get out of hand&lt;/strong&gt;: callbacks that run queries, touch the parent, or enqueue jobs add hidden cost to &lt;em&gt;every&lt;/em&gt; save (buried in the model); worse, they &lt;strong&gt;cascade&lt;/strong&gt; — one callback triggers another save, which triggers its callback… one write fans out into a chain of updates and cache invalidations, and can deadlock under concurrency. This is why the community calls callbacks a code smell.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;background job&lt;/strong&gt; (asynchronous): enqueue a job to update the redundant value after the write. &lt;strong&gt;Accept brief inconsistency&lt;/strong&gt; (eventual consistency) for fast writes — common for hot data. &lt;strong&gt;For maintaining redundant data, prefer a job (#6) over a synchronous callback&lt;/strong&gt; — no write slowdown, no cascade.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;periodic reconciliation job&lt;/strong&gt; (safety net): batch-recompute and fix any drifted values (like counter_cache's &lt;code&gt;reset_counters&lt;/code&gt;).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The point of denormalization isn't "store a copy" — it's this trade-off: not "store junk to go fast," but &lt;strong&gt;knowingly spending consistency complexity to buy read speed&lt;/strong&gt;, with a reconciliation backstop in place. Denormalize without thinking that through and you'll eventually get bitten by "why don't these two numbers match?"&lt;/p&gt;




&lt;h2&gt;
  
  
  🔗 Other ways to change the data model
&lt;/h2&gt;

&lt;p&gt;Beyond denormalization, a few common schema-level moves — reach for them when the situation calls for it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Polymorphic associations perform poorly&lt;/strong&gt;: &lt;code&gt;commentable_type + commentable_id&lt;/code&gt; can't have a real foreign key and can't JOIN efficiently to a single table. At scale, switch to &lt;strong&gt;explicit associations&lt;/strong&gt; (separate FK columns/tables) to win back integrity and performance.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;has_many :through&lt;/code&gt; join tables&lt;/strong&gt;: slow without indexes — the join table needs a composite index on &lt;code&gt;(a_id, b_id)&lt;/code&gt; (and the reverse pair if you query both directions).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data lifecycle on big tables&lt;/strong&gt;: time-series data (stock candles, logs) explodes in size → &lt;strong&gt;partitioning&lt;/strong&gt; (by time), &lt;strong&gt;hot/cold separation&lt;/strong&gt;, &lt;strong&gt;archiving&lt;/strong&gt;, keeping the main table lean so queries scan only the relevant partition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Column types&lt;/strong&gt;: money/prices in &lt;code&gt;decimal&lt;/code&gt;, not &lt;code&gt;float&lt;/code&gt; (float has rounding errors); the right time type — schema-design fundamentals.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These all change the schema structure; the cost and risk are far higher than rewriting a query — the last layer you reach for, after confirming the earlier moves aren't enough.&lt;/p&gt;




&lt;h2&gt;
  
  
  🏁 Wrap-up: the whole series as one map
&lt;/h2&gt;

&lt;p&gt;"When should I change the data model?" is answered by a bigger question — &lt;strong&gt;how to work through a slow request, one layer at a time.&lt;/strong&gt; That's the spine of this whole series. Performance optimization is really three axes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;① Make each request do less work&lt;/strong&gt; (this axis has a light-to-heavy order)&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Measure first&lt;/strong&gt; (APM / &lt;code&gt;EXPLAIN&lt;/code&gt;) — find the real bottleneck, don't guess.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tune queries and indexes first&lt;/strong&gt;: fix N+1, add composite indexes, don't drag data into Ruby (#1–#3). ~80% of problems end here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Then cache&lt;/strong&gt;: don't recompute what you already computed (#4, #5).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trim the Ruby-layer waste&lt;/strong&gt;: serialization, allocation, memoization (#7).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only then change the data model&lt;/strong&gt;: when the query pattern conflicts with the normalized schema, denormalize judiciously (counter_cache, redundant hot data, materialized views); changing schema structure (splitting tables, partitioning) is more deliberate still (#9, this post).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;② Move slow work out of the request&lt;/strong&gt; — email, third-party APIs, reports shouldn't block a request the user is waiting on; push them to the background (#6).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;③ Let the machine serve more requests at once&lt;/strong&gt; — no matter how fast the code, the wrong Puma / connection-pool / CDN setup still causes a traffic jam (#8).&lt;/p&gt;

&lt;p&gt;One principle:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Performance optimization = &lt;strong&gt;make each request do less + move slow work out of the request + let the machine serve more at once&lt;/strong&gt; — and each one starts the same way: &lt;strong&gt;measure first, then go from light to heavy.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Back to this post's layer: normalization is the right default; denormalization is a deliberate trade — spending consistency complexity to buy read speed — used only when measurement proves queries, indexes, and caching aren't enough, and always with a reconciliation backstop.&lt;/p&gt;

&lt;p&gt;After these nine posts, facing a slow page you hold not just a pile of tricks but a sense of &lt;strong&gt;which layer to reach for first&lt;/strong&gt; — and that judgment tends to decide the outcome more than any single technique.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>performance</category>
      <category>database</category>
      <category>postgres</category>
    </item>
    <item>
      <title>The Code Is Fine, but Requests Queue Until They Time Out: Puma, Pools, CDN</title>
      <dc:creator>Dane Wu</dc:creator>
      <pubDate>Sun, 28 Jun 2026 09:30:00 +0000</pubDate>
      <link>https://dev.to/danewu/the-code-is-fine-but-requests-queue-until-they-time-out-puma-pools-cdn-15lj</link>
      <guid>https://dev.to/danewu/the-code-is-fine-but-requests-queue-until-they-time-out-puma-pools-cdn-15lj</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Rails Performance: Lessons from Production — #8&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The previous six posts optimized the &lt;em&gt;code&lt;/em&gt; — queries, caching, background work, the app layer. But sometimes every request is fast on its own, and yet under concurrency the whole batch slows down or times out. The problem isn't the code, it's &lt;strong&gt;how the machine is configured&lt;/strong&gt;: how many Puma workers/threads, whether the connection pool is big enough, whether static assets go through a CDN. This post covers that last layer.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  💥 A single request is 50ms, but 100 at once time out
&lt;/h2&gt;

&lt;p&gt;Load testing turned up something odd: one request alone takes 50ms, but &lt;strong&gt;100 arriving at once&lt;/strong&gt; make the later ones queue, and some time out outright. The code didn't get slower — "&lt;strong&gt;how many it can handle at once&lt;/strong&gt;" isn't enough.&lt;/p&gt;

&lt;p&gt;That's the infrastructure layer — how many requests your app can serve in parallel is decided by &lt;strong&gt;Puma's config&lt;/strong&gt;, not by your code.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧵 Puma: workers (processes) × threads
&lt;/h2&gt;

&lt;p&gt;Puma is Rails' default web server, and its concurrency comes from two dimensions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;workers (processes)&lt;/strong&gt;: controlled by &lt;code&gt;WEB_CONCURRENCY&lt;/code&gt;. Each worker is a separate Ruby process and uses its own slice of memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;threads&lt;/strong&gt;: each worker runs N threads, handling N requests at once.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config/puma.rb&lt;/span&gt;
&lt;span class="n"&gt;workers&lt;/span&gt; &lt;span class="no"&gt;Integer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"WEB_CONCURRENCY"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;   &lt;span class="c1"&gt;# number of processes&lt;/span&gt;
&lt;span class="n"&gt;threads&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;                                        &lt;span class="c1"&gt;# 5 threads per worker&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Requests handled in parallel ≈ workers × threads.&lt;/strong&gt; Above that's &lt;code&gt;2 × 5 = 10&lt;/code&gt;. The "100 at once" from the opening far exceeds that, so the rest queue.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to tune:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Worker count&lt;/strong&gt;: each worker uses its own memory, so the real ceiling on workers is &lt;strong&gt;usually memory, not CPU cores.&lt;/strong&gt; CPU-bound work uses core count as a reference; an IO-bound Rails app often runs more workers than cores. Load test to find it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Thread count&lt;/strong&gt;: MRI runs only one thread of Ruby at a time (the GVL, formerly "GIL"), so for pure CPU work more threads help little. But &lt;strong&gt;the moment a thread enters blocking IO (DB, external API) it releases the GVL&lt;/strong&gt;, letting another thread run in that gap — so for a Rails app that spends lots of time waiting on IO, more threads help a lot. The more IO waiting, the bigger the win.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;There's no universal number — &lt;strong&gt;load test while watching memory and CPU.&lt;/strong&gt; The point is to know that "parallel ceiling = workers × threads," and to add capacity here when it's not enough.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  🔌 Connection pool: align threads with DB connections
&lt;/h2&gt;

&lt;p&gt;This is the most common hidden trap. Every Puma thread that queries the DB needs a DB connection, taken from the &lt;strong&gt;connection pool&lt;/strong&gt;. If the &lt;strong&gt;pool is smaller than the thread count&lt;/strong&gt;, threads can't get a connection, stall waiting, and eventually error:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ActiveRecord::ConnectionTimeoutError: could not obtain a connection from the pool
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Rule: each worker's &lt;code&gt;pool&lt;/code&gt; ≥ that worker's thread count.&lt;/strong&gt;&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config/database.yml&lt;/span&gt;
&lt;span class="na"&gt;production&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pool&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;&amp;lt;%= ENV.fetch("RAILS_MAX_THREADS", 5) %&amp;gt;&lt;/span&gt;   &lt;span class="c1"&gt;# align with threads&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And do the total math. The pool is &lt;strong&gt;per-process&lt;/strong&gt; — all threads in a worker share that worker's single pool, so the real total of connections hitting the DB is &lt;strong&gt;&lt;code&gt;workers × pool&lt;/code&gt;&lt;/strong&gt; (not &lt;code&gt;× threads&lt;/code&gt;; they happen to be equal when you set pool = threads, but pool is often set a bit larger to leave room for Active Storage, &lt;code&gt;load_async&lt;/code&gt;, and other non-request paths). That total must not exceed the DB's &lt;code&gt;max_connections&lt;/code&gt; (PostgreSQL defaults to 100 — but the usable number is lower after reserved connections and other services, and managed DBs often scale it by instance size).&lt;/p&gt;

&lt;p&gt;When connections genuinely run short, put &lt;strong&gt;PgBouncer&lt;/strong&gt; in front so many app connections share a few DB connections — but don't treat it as a free switch: the big savings come from its &lt;strong&gt;transaction mode&lt;/strong&gt;, and transaction mode conflicts with the prepared statements Rails enables by default, so you'll need &lt;code&gt;prepared_statements: false&lt;/code&gt; or a recent PgBouncer with prepared-statement support.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;In one line: &lt;strong&gt;Puma threads → pool size → DB max_connections — align this chain top to bottom;&lt;/strong&gt; any link too small and it stalls.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  🌐 CDN: don't make your Rails server ship static assets
&lt;/h2&gt;

&lt;p&gt;Every image, every JS/CSS file served by your Rails server means spending a precious Puma thread to ship a file — wasteful. Those &lt;strong&gt;static, unchanging assets&lt;/strong&gt; should go to a &lt;strong&gt;CDN&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Static assets (JS/CSS/images)&lt;/strong&gt;: served from the CDN, fetched from a nearby edge node — fast, and off your server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Active Storage uploads&lt;/strong&gt;: configure them to go through the CDN too, instead of streaming from your app / object storage every time.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A CDN offloads "shipping files" entirely from your Rails server, leaving Puma threads for "requests that actually run Ruby."&lt;/p&gt;




&lt;h2&gt;
  
  
  🚀 Other production switches worth flipping on
&lt;/h2&gt;

&lt;p&gt;A few low-cost, high-value settings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;bootsnap&lt;/code&gt;&lt;/strong&gt;: caches compiled Ruby/YAML, speeding up boot (faster deploys and restarts).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Asset precompilation (&lt;code&gt;assets:precompile&lt;/code&gt;)&lt;/strong&gt;: compile JS/CSS ahead of time, instead of compiling on a user's request.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enable gzip / compression&lt;/strong&gt;: compress responses before sending, saving bandwidth.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are usually handled by Rails defaults or your deploy platform, but knowing they exist and confirming they're on is table stakes.&lt;/p&gt;




&lt;h2&gt;
  
  
  🏁 Wrap-up
&lt;/h2&gt;

&lt;p&gt;When each request is fast on its own but concurrency collapses, the problem is the infrastructure layer:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;setting&lt;/th&gt;
&lt;th&gt;governs&lt;/th&gt;
&lt;th&gt;key point&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Puma workers × threads&lt;/td&gt;
&lt;td&gt;how many requests at once&lt;/td&gt;
&lt;td&gt;the parallel ceiling — tune by load testing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;connection pool&lt;/td&gt;
&lt;td&gt;whether threads can get a DB connection&lt;/td&gt;
&lt;td&gt;pool ≥ threads; total (&lt;code&gt;workers × pool&lt;/code&gt;) ≤ DB max_connections&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CDN&lt;/td&gt;
&lt;td&gt;who ships static assets&lt;/td&gt;
&lt;td&gt;offload to the CDN, keep Puma for Ruby requests&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One principle:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The first six posts were about "make each request faster"; this one is about "let the machine serve more requests at once."&lt;/strong&gt; Different problems — no matter how fast the code, the wrong concurrency config still causes a traffic jam.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And this layer has a chain: &lt;strong&gt;Puma threads → connection pool → DB connection ceiling&lt;/strong&gt; — align it top to bottom. Before bumping Puma, ask "can my DB handle this many connections?" That's why this post comes last: it's the bill you add up when you fit all the earlier optimizations into a real machine.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>performance</category>
      <category>puma</category>
      <category>postgres</category>
    </item>
    <item>
      <title>Your SQL Is Fast but the API Is Slow: It's the Ruby Layer</title>
      <dc:creator>Dane Wu</dc:creator>
      <pubDate>Sun, 28 Jun 2026 09:26:47 +0000</pubDate>
      <link>https://dev.to/danewu/your-sql-is-fast-but-the-api-is-slow-its-the-ruby-layer-2fno</link>
      <guid>https://dev.to/danewu/your-sql-is-fast-but-the-api-is-slow-its-the-ruby-layer-2fno</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Rails Performance: Lessons from Production — #7&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;By now the DB queries, caching, and background work are handled. But sometimes the SQL is genuinely fast and the API is &lt;em&gt;still&lt;/em&gt; slow, the CPU &lt;em&gt;still&lt;/em&gt; high — because the bottleneck moved into &lt;strong&gt;Ruby itself&lt;/strong&gt;: heavy serialization, allocating too many objects, recomputing the same thing. This post is about the cost in the application layer, after the request reaches the controller. Same example throughout (a {% raw %}&lt;code&gt;shipments&lt;/code&gt; table).&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  💥 SQL took 30ms, the API took 800ms
&lt;/h2&gt;

&lt;p&gt;A shipments-list API. The APM showed &lt;strong&gt;SQL was only 30ms, but the whole request took 800ms&lt;/strong&gt;. N+1 and indexes were already checked — the DB was fine. The slow part was "what Ruby does after the data comes back."&lt;/p&gt;

&lt;p&gt;That remaining 770ms went to three common places: &lt;strong&gt;bloated serialization, too many intermediate objects, and the same computation running repeatedly.&lt;/strong&gt; Let's take them one at a time.&lt;/p&gt;




&lt;h2&gt;
  
  
  📦 Trap 1: serializing a pile of fields nobody uses
&lt;/h2&gt;

&lt;p&gt;The most common one — the API turns the whole model into JSON:&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="n"&gt;render&lt;/span&gt; &lt;span class="ss"&gt;json: &lt;/span&gt;&lt;span class="vi"&gt;@shipments&lt;/span&gt;   &lt;span class="c1"&gt;# every field of every shipment gets dumped&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Problems:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A shipment has 40 columns, the frontend uses 5 — serializing the other 35 × thousands of rows is pure waste.&lt;/li&gt;
&lt;li&gt;You might also leak &lt;strong&gt;sensitive fields&lt;/strong&gt; (internal cost, API keys) by accident.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The fix: return only the fields you need.&lt;/strong&gt; Spell them out:&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="n"&gt;render&lt;/span&gt; &lt;span class="ss"&gt;json: &lt;/span&gt;&lt;span class="vi"&gt;@shipments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;as_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;only: &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:tracking_no&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:status&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Serialization is pure CPU work — fewer fields × more rows, more saved.&lt;/p&gt;

&lt;p&gt;Two things worth adding:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Safety should come from a structural whitelist, not from remembering &lt;code&gt;only:&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;as_json&lt;/code&gt; dumps &lt;strong&gt;all fields&lt;/strong&gt; by default, and &lt;code&gt;only:&lt;/code&gt; is a manual whitelist the next person easily rewrites back to &lt;code&gt;render json: @shipments&lt;/code&gt;. To make "new fields don't leak by default" a structural guarantee, use a serializer (Alba / blueprinter / AMS) that pins the visible schema.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;as_json(only:)&lt;/code&gt; saves serialized fields, but doesn't save "instantiating every row into an AR object + building a Hash per row"&lt;/strong&gt; — which is exactly the cost in Trap 2. When fields are few but rows are huge and you want to skip instantiation too, use &lt;code&gt;pluck(:id, :tracking_no, :status)&lt;/code&gt; to fetch arrays and never build AR objects at all.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  ♻️ Trap 2: allocating too many intermediate objects in a hot path (GC pressure)
&lt;/h2&gt;

&lt;p&gt;Every object Ruby creates costs memory; lots of allocation increases &lt;strong&gt;GC (garbage collection)&lt;/strong&gt; work and grows the heap, which drags throughput once it scales. (MRI's GC is generational + incremental, so short-lived objects are actually relatively cheap — the truly expensive thing is "many, long-lived, growing the heap." So the point is: don't allocate pointlessly in a hot path.) It shows up most on a path that runs "for every row":&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;# ❌ each shipment builds a new hash, a new string, runs a format&lt;/span&gt;
&lt;span class="vi"&gt;@shipments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;label: &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;tracking_no&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; - &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;upcase&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; - &lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;format_date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Thousands of rows × several intermediate objects each = tens of thousands of short-lived objects thrown at the GC.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The fix&lt;/strong&gt;: allocate fewer intermediates in the hot path — push what the DB can compute to the DB (back to #3), merge steps, and don't rebuild the same thing inside the loop.&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;# pull the invariant out of the loop, don't rebuild it each iteration&lt;/span&gt;
&lt;span class="n"&gt;status_labels&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;"delivered"&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;"Delivered"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;# built once&lt;/span&gt;
&lt;span class="vi"&gt;@shipments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;status_labels&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Concept: &lt;strong&gt;object allocation has a cost.&lt;/strong&gt; One or two don't matter, but amplified by "every row × every request" the GC becomes a hidden bottleneck.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  🧮 Trap 3: the same computation, run many times in one request
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;total_weight&lt;/span&gt;
  &lt;span class="n"&gt;shipments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="ss"&gt;:weight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# recomputes on every call&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="c1"&gt;# in one request, the view, a helper, and the serializer each call it → computed three times&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The fix: memoization — compute once, reuse within the same request.&lt;/strong&gt;&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;def&lt;/span&gt; &lt;span class="nf"&gt;total_weight&lt;/span&gt;
  &lt;span class="vi"&gt;@total_weight&lt;/span&gt; &lt;span class="o"&gt;||=&lt;/span&gt; &lt;span class="n"&gt;shipments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="ss"&gt;:weight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# compute first time, then just return it&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;@x ||= ...&lt;/code&gt; means "compute only when &lt;code&gt;@x&lt;/code&gt; is &lt;strong&gt;nil or false&lt;/strong&gt;, otherwise return it." Within the same object, in the same request, this runs once.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;⚠️ The &lt;code&gt;||=&lt;/code&gt; trap: it tests "is the value nil/false," not "has it been computed." If the result is &lt;em&gt;legitimately&lt;/em&gt; &lt;code&gt;nil&lt;/code&gt; or &lt;code&gt;false&lt;/code&gt; (e.g. a lookup that returns nil), every call &lt;strong&gt;recomputes&lt;/strong&gt; and memoization is effectively dead — a hidden perf bug on an expensive query. For that, write &lt;code&gt;@x = compute unless defined?(@x)&lt;/code&gt;. (&lt;code&gt;sum&lt;/code&gt; returns a number so it dodges this, but you should know the general gotcha.)&lt;/p&gt;

&lt;p&gt;Also: memoization works "within one request" because &lt;strong&gt;the object being memoized on is itself built per-request&lt;/strong&gt; (GC'd when the request ends) — not because the mechanism is request-scoped. Memoize on a &lt;strong&gt;class-level / singleton / long-lived service&lt;/strong&gt; object and it &lt;strong&gt;persists across requests&lt;/strong&gt;, causing stale data or memory leaks. Be careful.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  🏁 Wrap-up
&lt;/h2&gt;

&lt;p&gt;When the DB is already fast but the API is still slow, the bottleneck is usually in the Ruby layer:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;trap&lt;/th&gt;
&lt;th&gt;symptom&lt;/th&gt;
&lt;th&gt;fix&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;bloated serialization&lt;/td&gt;
&lt;td&gt;returning fields nobody uses&lt;/td&gt;
&lt;td&gt;return only what's needed (&lt;code&gt;only:&lt;/code&gt; / a serializer / &lt;code&gt;pluck&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;too much allocation&lt;/td&gt;
&lt;td&gt;hot path builds many intermediates, high GC&lt;/td&gt;
&lt;td&gt;allocate fewer intermediates, push to the DB&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;repeated computation&lt;/td&gt;
&lt;td&gt;same computation many times per request&lt;/td&gt;
&lt;td&gt;memoization (`@x&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One principle:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;DB optimization is "ask the database for less"; application-layer optimization is "do less busywork after the data comes back."&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Each of these looks like "saves a tiny bit" on its own, but they share one amplifier — &lt;strong&gt;"every row × every request."&lt;/strong&gt; One field, one object, one computation, times thousands of rows and thousands of requests, is exactly those vanished hundred-odd milliseconds. Confirm with an APM that it's genuinely the Ruby layer (SQL fast, request slow) before digging in here.&lt;/p&gt;



</description>
      <category>rails</category>
      <category>performance</category>
      <category>ruby</category>
      <category>webdev</category>
    </item>
    <item>
      <title>One Notification Email Made Our Checkout API 3 Seconds Slower</title>
      <dc:creator>Dane Wu</dc:creator>
      <pubDate>Sun, 28 Jun 2026 09:25:58 +0000</pubDate>
      <link>https://dev.to/danewu/one-notification-email-made-our-checkout-api-3-seconds-slower-34oj</link>
      <guid>https://dev.to/danewu/one-notification-email-made-our-checkout-api-3-seconds-slower-34oj</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Rails Performance: Lessons from Production — #6&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The earlier posts optimized what happens &lt;em&gt;inside&lt;/em&gt; a request — fewer queries, indexes, caching. This one flips the angle: &lt;strong&gt;some slowness shouldn't happen in the request at all.&lt;/strong&gt; Sending email, calling a third party, generating a report — push that slow work to the background and let the request return immediately. Same example throughout (a &lt;code&gt;shipments&lt;/code&gt; table).&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  💥 The checkout was fast, but the user waited 3 seconds
&lt;/h2&gt;

&lt;p&gt;Placing an order is fast in itself — writing one row to the DB takes 10ms. But after the user hits "checkout," the screen &lt;strong&gt;froze for 3 seconds&lt;/strong&gt; before responding.&lt;/p&gt;

&lt;p&gt;The controller looked like 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="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;
  &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;         &lt;span class="c1"&gt;# 10ms&lt;/span&gt;
  &lt;span class="no"&gt;CourierApi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_shipment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;            &lt;span class="c1"&gt;# call the courier API: 1.5s&lt;/span&gt;
  &lt;span class="no"&gt;OrderMailer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;confirmation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;deliver_now&lt;/span&gt;  &lt;span class="c1"&gt;# send the confirmation email: 1.2s&lt;/span&gt;
  &lt;span class="n"&gt;render&lt;/span&gt; &lt;span class="ss"&gt;json: &lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The order was created long ago, but the user has to &lt;strong&gt;sit and wait for the courier API and the email&lt;/strong&gt; before seeing a result. Worse: &lt;strong&gt;the moment the courier API slows down or goes down, the whole checkout request hangs or times out&lt;/strong&gt; — the user thinks checkout failed, when in fact the order was created.&lt;/p&gt;

&lt;p&gt;The root issue: &lt;strong&gt;cramming "slow, and not needed right now" work into a request the user is waiting on.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  📤 The fix: push it to the background, return immediately
&lt;/h2&gt;

&lt;p&gt;Turn that slow work into a &lt;strong&gt;background job&lt;/strong&gt; — the request only enqueues the task and returns; the actual work runs on a background worker:&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;def&lt;/span&gt; &lt;span class="nf"&gt;create&lt;/span&gt;
  &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create!&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# 10ms&lt;/span&gt;
  &lt;span class="no"&gt;CreateShipmentJob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform_later&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;# enqueue, ~0ms&lt;/span&gt;
  &lt;span class="no"&gt;OrderMailer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;confirmation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;deliver_later&lt;/span&gt; &lt;span class="c1"&gt;# email goes to the background too&lt;/span&gt;
  &lt;span class="n"&gt;render&lt;/span&gt; &lt;span class="ss"&gt;json: &lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;                           &lt;span class="c1"&gt;# return immediately, the user doesn't wait&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What &lt;code&gt;perform_later&lt;/code&gt; does: &lt;strong&gt;writes "what to do + arguments" into a queue (Redis or the database, depending on your engine) and returns at once.&lt;/strong&gt; The request finishes in ~20ms.&lt;/p&gt;

&lt;p&gt;A separate worker process pulls the task off the queue and runs 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="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;CreateShipmentJob&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationJob&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;perform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="no"&gt;CourierApi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_shipment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# this 1.5s runs in the background, nobody waits&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The user's perceived time drops from 3 seconds back to 20ms. If the courier API is slow or down, it only affects that background job (which can retry) — it &lt;strong&gt;doesn't drag down checkout&lt;/strong&gt;.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;What belongs in the background&lt;/strong&gt;: email, third-party APIs, reports/exports, image processing, push notifications — the common thread is "slow, and the user doesn't need to see the result right now."&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  ⚙️ The tools: Active Job + Sidekiq / Solid Queue
&lt;/h2&gt;

&lt;p&gt;Rails' &lt;code&gt;perform_later&lt;/code&gt; is &lt;strong&gt;Active Job&lt;/strong&gt; — a unified interface; you can swap the underlying queue engine:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Sidekiq&lt;/strong&gt;: the mainstream choice, uses Redis as the queue, fast and mature.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Solid Queue&lt;/strong&gt;: the new Rails 8 default, uses the &lt;strong&gt;database&lt;/strong&gt; as the queue, no separate Redis to run.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# config/application.rb&lt;/span&gt;
&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;active_job&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;queue_adapter&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="ss"&gt;:sidekiq&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You don't rewrite your job code to switch engines — that's the value of Active Job.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧨 The traps (the stuff you only hit after you've shipped it)
&lt;/h2&gt;

&lt;p&gt;Pushing work to the background isn't "fire and forget." A few you must understand:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Idempotency — a job can run more than once&lt;/strong&gt;&lt;br&gt;
Background queues are almost always &lt;strong&gt;at-least-once&lt;/strong&gt;: a job may finish successfully but the worker crashes — or gets &lt;code&gt;SIGTERM&lt;/code&gt;'d during a deploy — &lt;em&gt;before it reports completion (ack)&lt;/em&gt;. The queue thinks it didn't finish, re-dispatches it, and it runs again. (Retries are another source: Sidekiq's own Worker API retries 25 times by default; but &lt;strong&gt;bare Active Job doesn't retry by default&lt;/strong&gt; — you add &lt;code&gt;retry_on&lt;/code&gt; yourself.) So a job must be designed so that &lt;strong&gt;running it twice gives the same result as running it once.&lt;/strong&gt;&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;def&lt;/span&gt; &lt;span class="nf"&gt;perform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shipment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;present?&lt;/span&gt;   &lt;span class="c1"&gt;# catches most duplicates&lt;/span&gt;
  &lt;span class="no"&gt;CourierApi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create_shipment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;Note this &lt;code&gt;present?&lt;/code&gt; is "check-then-act": &lt;strong&gt;under concurrency two jobs can both pass the check and both create a shipment.&lt;/strong&gt; Real protection needs a DB unique index, or an idempotency key sent to the third-party API so &lt;em&gt;they&lt;/em&gt; dedupe. A guard clause only catches the common case — it isn't true idempotency.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;2. Pass an id, not the whole object&lt;/strong&gt;&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;CreateShipmentJob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform_later&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;       &lt;span class="c1"&gt;# works, but not recommended&lt;/span&gt;
&lt;span class="no"&gt;CreateShipmentJob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform_later&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;# ✅ pass the id&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;First, clear up a common misconception: passing the whole object does &lt;strong&gt;not&lt;/strong&gt; stuff all its data into the queue — Active Job uses &lt;strong&gt;GlobalID&lt;/strong&gt;, storing just a &lt;code&gt;gid://app/Order/123&lt;/code&gt; reference and re-&lt;code&gt;find&lt;/code&gt;ing it at execution time, so you get &lt;strong&gt;the latest data anyway&lt;/strong&gt; (just as fresh as finding it yourself).&lt;/p&gt;

&lt;p&gt;So why still pass the id? The real reasons: ① if the object is &lt;strong&gt;deleted&lt;/strong&gt; before the job runs, GlobalID deserialization raises &lt;code&gt;ActiveJob::DeserializationError&lt;/code&gt; and the whole job fails — passing an id lets you decide what to do when &lt;code&gt;find&lt;/code&gt; misses; ② smaller payload, clearer intent; ③ a plain PORO (not ActiveRecord) has no GlobalID, so you must pass an id anyway.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;A useful way to see it: pass an id when you cross &lt;strong&gt;your own queue boundary&lt;/strong&gt; (re-fetched later by the same system). Inside &lt;code&gt;perform&lt;/code&gt;, normal in-process calls pass the live object — &lt;code&gt;CourierApi.create_shipment(order)&lt;/code&gt; hands over the object because the external API can't look up your internal id; it needs the actual fields to build its request.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;3. Use queue priorities&lt;/strong&gt;&lt;br&gt;
Email can wait, but "payment succeeded" needs to be quick. Split jobs across queues with different priorities:&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;CreateShipmentJob&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;ApplicationJob&lt;/span&gt;
  &lt;span class="n"&gt;queue_as&lt;/span&gt; &lt;span class="ss"&gt;:critical&lt;/span&gt;   &lt;span class="c1"&gt;# important work on a high-priority queue&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Otherwise a pile of low-priority report jobs clogs the queue and the urgent ones wait behind them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Cap retries, and handle final failure&lt;/strong&gt;&lt;br&gt;
Auto-retry is great, but infinite retries on a "courier API permanently down" is just waste. Set a retry limit; past it, move to a &lt;strong&gt;dead letter queue&lt;/strong&gt; or alert a human — don't let it vanish silently. (Terminology varies by engine: Sidekiq calls it the dead set; Solid Queue records failures in the &lt;code&gt;failed_executions&lt;/code&gt; table.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. Keep jobs small and fast&lt;/strong&gt;&lt;br&gt;
A job that runs for 30 minutes ties up a worker for 30 minutes, and if it dies halfway it reruns from the start. Break big work up (e.g. &lt;code&gt;find_each&lt;/code&gt; batch by batch, one job per batch).&lt;/p&gt;




&lt;h2&gt;
  
  
  🏁 Wrap-up
&lt;/h2&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;in the request (❌)&lt;/th&gt;
&lt;th&gt;background job (✅)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;user experience&lt;/td&gt;
&lt;td&gt;waits for the slow work&lt;/td&gt;
&lt;td&gt;returns immediately&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;third party goes down&lt;/td&gt;
&lt;td&gt;whole request hangs / times out&lt;/td&gt;
&lt;td&gt;only that job is affected, retryable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;web worker&lt;/td&gt;
&lt;td&gt;held hostage by slow work&lt;/td&gt;
&lt;td&gt;freed up to serve others&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One principle:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The request does only what the user needs right now; slow work that can finish later goes to the background.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;But the background buys a new class of responsibilities — jobs retry (be idempotent), run later (pass ids, not objects), and fail (cap retries and handle it). &lt;strong&gt;"Push it to the background" is easy; "push it correctly" is what makes you senior&lt;/strong&gt; — the moment you enqueue it, think about what happens when it retries, runs late, or fails.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>performance</category>
      <category>ruby</category>
      <category>sidekiq</category>
    </item>
    <item>
      <title>We Added a Cache; Three Days Later It Took the Database Down at Peak</title>
      <dc:creator>Dane Wu</dc:creator>
      <pubDate>Sat, 27 Jun 2026 01:39:38 +0000</pubDate>
      <link>https://dev.to/danewu/we-added-a-cache-three-days-later-it-took-the-database-down-at-peak-p46</link>
      <guid>https://dev.to/danewu/we-added-a-cache-three-days-later-it-took-the-database-down-at-peak-p46</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Rails Performance: Lessons from Production — #5 · Caching, part 2&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The &lt;a href="https://dev.to/danewu/the-fastest-query-is-the-one-you-never-run-the-four-layers-of-rails-caching-fif"&gt;previous post&lt;/a&gt; covered how caching works (the four layers). This one is about how it bites — the failure modes you only meet after it ships. Adding a cache is one line of &lt;code&gt;fetch&lt;/code&gt;, but what it buys you is a whole new class of problems: hammering the DB the instant a key expires, logic breaking when the cache vanishes, memory blowing up. Same example throughout (a &lt;code&gt;shipments&lt;/code&gt; table).&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  🐎 Trap 1: the instant it expires, everyone hammers the DB at once (stampede)
&lt;/h2&gt;

&lt;p&gt;We cached the homepage's 800ms ranking stat with &lt;code&gt;expires_in: 5.minutes&lt;/code&gt;. The homepage flew. Felt great.&lt;/p&gt;

&lt;p&gt;Three days later, at peak hour, DB CPU suddenly pegged at 100% and the whole site slowed down. The APM showed a spike every 5 minutes — like clockwork.&lt;/p&gt;

&lt;p&gt;The cause was a &lt;strong&gt;cache stampede&lt;/strong&gt;: the instant that key expired, all the requests arriving at that moment &lt;strong&gt;missed simultaneously and all ran that same 800ms query at once&lt;/strong&gt;. The cache was supposed to protect the DB; instead, "everyone expiring at the same instant" made the DB take a huge spike in that one second.&lt;/p&gt;

&lt;p&gt;How big a spike? It's not about raw RPS — it's &lt;strong&gt;RPS × recompute time&lt;/strong&gt;. The query takes 800ms; during that 0.8s "miss window," every arriving request finds nothing and recomputes. Even a modest 50 RPS means ~40 requests running the same 800ms query in parallel — the DB starts gasping.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to fix:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Don't give a pile of keys the same hard expiry.&lt;/strong&gt; Switch to key-based (tied to the data version) and there's no "everyone expires at the same instant" time bomb.&lt;/li&gt;
&lt;li&gt;If you genuinely need time-based expiry, use Rails' &lt;code&gt;race_condition_ttl&lt;/code&gt;:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;Rails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"courier_ranking"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;expires_in: &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;race_condition_ttl: &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;seconds&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="no"&gt;Shipment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:courier_id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After expiry, &lt;strong&gt;only the first request that misses recomputes&lt;/strong&gt;; the others &lt;strong&gt;get the just-expired old value immediately and return — they don't wait&lt;/strong&gt;. Once the first finishes (~0.8s), the new value lands. &lt;code&gt;race_condition_ttl: 10.seconds&lt;/code&gt; isn't "everyone waits 10s" — it's the &lt;strong&gt;maximum grace period&lt;/strong&gt; the old value is kept alive in case the recompute drags.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson: a cache protects the DB, but "caches expiring together" concentrates traffic onto the DB.&lt;/strong&gt; When you add a cache, plan for the instant it expires.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧱 Trap 2: treating the cache as the source of truth — Redis restarts and it crashes
&lt;/h2&gt;

&lt;p&gt;Some logic was written like this — write a setting into the cache, then read straight back from the cache later:&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;Rails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"active_courier_ids"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;Courier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;active&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pluck&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:id&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;span class="c1"&gt;# ... elsewhere ...&lt;/span&gt;
&lt;span class="n"&gt;ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Rails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"active_courier_ids"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;ids&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;# one day this blew up: ids was nil&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A Redis maintenance restart wiped the cache. That &lt;code&gt;read&lt;/code&gt; returned &lt;code&gt;nil&lt;/code&gt;, &lt;code&gt;nil.each&lt;/code&gt; raised &lt;code&gt;NoMethodError&lt;/code&gt;, and the feature died.&lt;/p&gt;

&lt;p&gt;The root issue: &lt;strong&gt;we treated the cache as the "home" of the data.&lt;/strong&gt; But a cache &lt;strong&gt;can disappear at any moment&lt;/strong&gt; — Redis restarts, memory fills and entries get evicted, keys expire. It's not a database.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to fix: a cache can only ever be a "recomputable copy," never the source of truth.&lt;/strong&gt; If it's missing, you must be able to recompute it — use &lt;code&gt;fetch&lt;/code&gt; (recomputes on a miss) instead of a bare &lt;code&gt;read&lt;/code&gt;:&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="n"&gt;ids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Rails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"active_courier_ids"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="no"&gt;Courier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;active&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pluck&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Lesson: always assume the cache will be gone the next second.&lt;/strong&gt; Any code that "breaks if the cache is missing" is a landmine.&lt;/p&gt;




&lt;h2&gt;
  
  
  💣 Trap 3: putting something variable in the key — Redis memory blows up
&lt;/h2&gt;

&lt;p&gt;To cache each user's search results, someone set the key like 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="no"&gt;Rails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"search/&lt;/span&gt;&lt;span class="si"&gt;#{&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:q&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;expensive_search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:q&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks reasonable. But &lt;code&gt;params[:q]&lt;/code&gt; is &lt;strong&gt;free user input&lt;/strong&gt; — every distinct search string spawns a new key. Once live, all sorts of bizarre query strings poured in, the number of keys grew without bound, &lt;strong&gt;Redis memory climbed until it blew up&lt;/strong&gt;, and the whole cache service went down.&lt;/p&gt;

&lt;p&gt;The problem: &lt;strong&gt;the key's cardinality is out of control&lt;/strong&gt; — you think you're caching, but you're actually hoarding without limit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to fix:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Build keys only from &lt;strong&gt;bounded, controlled&lt;/strong&gt; dimensions (category, id), never drop raw user input straight into the key.&lt;/li&gt;
&lt;li&gt;Give Redis an &lt;strong&gt;eviction policy&lt;/strong&gt; (&lt;code&gt;maxmemory-policy&lt;/code&gt;), don't leave it at the default &lt;code&gt;noeviction&lt;/code&gt; (which rejects writes and errors when full). For caches, &lt;code&gt;allkeys-lru&lt;/code&gt; is common (drop the least-recently-used when full), but the point isn't which one — it's &lt;strong&gt;having a "drop the old when full" policy at all&lt;/strong&gt;, so it doesn't blow up entirely.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Lesson: cache space is finite.&lt;/strong&gt; Every key costs memory; a key that grows without bound is a memory leak.&lt;/p&gt;




&lt;h2&gt;
  
  
  ☠️ Trap 4: caching the &lt;em&gt;failed&lt;/em&gt; result
&lt;/h2&gt;

&lt;p&gt;The ranking stat was changed to come from an internal API:&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;Rails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"courier_ranking"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;expires_in: &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="no"&gt;RankingApi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;   &lt;span class="c1"&gt;# one time it returned nil (the API was briefly down)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That time the API briefly failed and returned &lt;code&gt;nil&lt;/code&gt;, and &lt;code&gt;fetch&lt;/code&gt; &lt;strong&gt;stored that &lt;code&gt;nil&lt;/code&gt; as "the result" for 5 minutes&lt;/strong&gt;. So the API was long since fixed, but for the next 5 minutes every user still got an empty ranking — a few-second outage amplified by the cache into a 5-minute site-wide failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How to fix: don't cache failed / empty results.&lt;/strong&gt; If you get an abnormal value, don't store it (or let it expire immediately):&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;Rails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"courier_ranking"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;expires_in: &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;RankingApi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;
  &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="s2"&gt;"empty"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;blank?&lt;/span&gt;   &lt;span class="c1"&gt;# don't let an empty result get stored&lt;/span&gt;
  &lt;span class="n"&gt;result&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Lesson: a cache faithfully preserves your errors too.&lt;/strong&gt; Before storing, confirm this is a result worth keeping for 5 minutes.&lt;/p&gt;




&lt;h2&gt;
  
  
  🏁 Wrap-up: the cost of caching
&lt;/h2&gt;

&lt;p&gt;Caching makes a system faster, but it buys a whole new class of problems. These four traps are four faces of one sentence:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A cache is a copy that can vanish at any moment, will be faithfully preserved, and occupies finite space — so always think about the instant it expires, disappears, blows up, or stores the wrong thing.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;the instant it expires&lt;/strong&gt; → don't let everyone expire together (stampede)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;the instant it vanishes&lt;/strong&gt; → don't treat it as the source of truth&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;the matter of space&lt;/strong&gt; → don't let keys grow without bound&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;the matter of correctness&lt;/strong&gt; → don't cache a failed result&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That one line of &lt;code&gt;fetch&lt;/code&gt; makes caching look easy. But what actually makes you senior is, the moment you add a cache, &lt;strong&gt;anticipating every way it can go wrong&lt;/strong&gt; — these four are the ones you only learn in production, and never forget once you do.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>performance</category>
      <category>caching</category>
      <category>redis</category>
    </item>
    <item>
      <title>The Fastest Query Is the One You Never Run: The Four Layers of Rails Caching</title>
      <dc:creator>Dane Wu</dc:creator>
      <pubDate>Sat, 27 Jun 2026 01:38:31 +0000</pubDate>
      <link>https://dev.to/danewu/the-fastest-query-is-the-one-you-never-run-the-four-layers-of-rails-caching-fif</link>
      <guid>https://dev.to/danewu/the-fastest-query-is-the-one-you-never-run-the-four-layers-of-rails-caching-fif</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Rails Performance: Lessons from Production — #4&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The first three posts were about making queries cheaper — fewer N+1s, indexes, not dragging data back into Ruby. This one flips the angle: &lt;strong&gt;the fastest query is the one you never run.&lt;/strong&gt; Compute a result once, store it, and hand it to everyone after. Same example throughout (a &lt;code&gt;shipments&lt;/code&gt; table), walking through the four layers of Rails caching: compute, invalidate, render, transfer.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  🔥 The same 800ms stat, recomputed for every visitor
&lt;/h2&gt;

&lt;p&gt;The homepage shows a "courier shipment ranking." It's heavy — scanning a few million shipments and a &lt;code&gt;GROUP BY&lt;/code&gt; takes 800ms.&lt;/p&gt;

&lt;p&gt;The thing is: that number &lt;strong&gt;looks the same to every user&lt;/strong&gt;, and it &lt;strong&gt;doesn't need to be real-time&lt;/strong&gt; (a few minutes stale is fine). But our code makes &lt;strong&gt;every visitor recompute it from scratch&lt;/strong&gt; — 800ms each. Under traffic, the DB gets ground down by the same calculation over and over.&lt;/p&gt;

&lt;p&gt;That's the sweet spot for caching — &lt;strong&gt;expensive&lt;/strong&gt; (worth saving) and &lt;strong&gt;hit repeatedly&lt;/strong&gt; (the same result is reused). Let's start from the most basic tool.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Continuing the four layers from &lt;a href="https://dev.to/danewu/from-8s-to-1s-truly-understanding-rails-n1-by-opening-up-activerecord-1oj"&gt;#1&lt;/a&gt;: the first three posts optimized "how data is fetched"; caching is "don't compute what you already computed." One idea runs through the whole post — use &lt;code&gt;updated_at&lt;/code&gt; as a "version," reuse while the data hasn't changed.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  📦 Layer 1 (compute): &lt;code&gt;Rails.cache.fetch&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;Wrap that 800ms calculation:&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;Rails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"courier_ranking"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;expires_in: &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minutes&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="no"&gt;Shipment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:courier_id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;   &lt;span class="c1"&gt;# the heavy calculation&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What &lt;code&gt;fetch&lt;/code&gt; does, in one line: &lt;strong&gt;if it's there, take it; if not, compute it and store it.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;First time: look up key → miss → run the block (800ms) → store it with a 5-minute expiry → return.&lt;/li&gt;
&lt;li&gt;Next 5 minutes: look up key → hit → &lt;strong&gt;return the stored value, the block never runs&lt;/strong&gt; (milliseconds).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So &lt;strong&gt;the first user pays 800ms, everyone else for the next 5 minutes gets it free.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Two key parameters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;key&lt;/strong&gt; (&lt;code&gt;"courier_ranking"&lt;/code&gt;): the cache's name. If the result varies by condition, the key must encode that, e.g. &lt;code&gt;"courier_stats/#{courier.id}"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;expires_in&lt;/code&gt;&lt;/strong&gt;: how long until it expires, i.e. "how stale can you tolerate." Shorten it for fresher data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;When is something worth caching?&lt;/strong&gt; ① &lt;strong&gt;Expensive&lt;/strong&gt; — high recompute cost, worth saving. ② &lt;strong&gt;Hit repeatedly&lt;/strong&gt; — the same result is used many times (across users, or the same query requested over and over).&lt;/p&gt;

&lt;p&gt;As for "freshness" — that's not a precondition, it's a problem to solve: tolerate some staleness → use &lt;code&gt;expires_in&lt;/code&gt;; need it current → use key-based invalidation so it updates the moment data changes (the next layer is exactly this).&lt;/p&gt;




&lt;h2&gt;
  
  
  🔑 Layer 2 (invalidate): key-based expiration
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;expires_in&lt;/code&gt; only handles "time's up," not "the data changed." You set 5 minutes, but a new shipment lands in between, and users see a stale number. How do you make the cache "update the moment data changes"?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;❌ The dumb way: manually delete the cache when data changes&lt;/strong&gt;&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="n"&gt;after_commit&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="no"&gt;Rails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"courier_ranking"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Problem: you have to remember "which operations affect which caches," and clear each one by hand. With many caches it becomes a nightmare — miss one and you've got a stale-data bug. This is the origin of the famous line "cache invalidation is one of the two hard problems."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;✅ The smart way: put the "version" in the key, so the key changes when the data does&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Rails generates a key for each record that changes with &lt;code&gt;updated_at&lt;/code&gt;:&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="n"&gt;courier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cache_key_with_version&lt;/span&gt;
&lt;span class="c1"&gt;# → "couriers/1-20260626120000"   ← the trailing part is the updated_at timestamp&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use it as the cache key:&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;Rails&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cache&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;courier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cache_key_with_version&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="n"&gt;expensive_render&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;courier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;courier unchanged → key unchanged → hit (old value) ✅&lt;/li&gt;
&lt;li&gt;courier modified → &lt;code&gt;updated_at&lt;/code&gt; changes → key changes → miss → recomputed automatically ✅&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;You never manually delete anything&lt;/strong&gt; — when data changes, the old key is abandoned and the new key naturally computes the new value.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Mindset: don't "clear the old cache," let "a data change produce a new key." That notorious cache-invalidation problem mostly disappears.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  🪆 Layer 3 (render): fragment + Russian Doll
&lt;/h2&gt;

&lt;p&gt;So far we cached computed results. But sometimes computing the data is fast and the slow part is &lt;strong&gt;rendering it into HTML&lt;/strong&gt; (lots of ERB, helpers). Here what you cache is the &lt;strong&gt;rendered HTML fragment&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Fragment caching&lt;/strong&gt;: wrap a chunk with &lt;code&gt;cache&lt;/code&gt; in the view:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight erb"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="vi"&gt;@couriers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;courier&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
  &lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt; &lt;span class="n"&gt;courier&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;          &lt;span class="c"&gt;&amp;lt;%# automatically uses courier.cache_key_with_version as the key %&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;div&lt;/span&gt; &lt;span class="na"&gt;class=&lt;/span&gt;&lt;span class="s"&gt;"courier-card"&lt;/span&gt;&lt;span class="nt"&gt;&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;h3&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;courier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/h3&amp;gt;&lt;/span&gt;
      &lt;span class="nt"&gt;&amp;lt;p&amp;gt;&lt;/span&gt;Shipments: &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;courier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shipments_count&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/p&amp;gt;&lt;/span&gt;
    &lt;span class="nt"&gt;&amp;lt;/div&amp;gt;&lt;/span&gt;
  &lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;First time → miss → run ERB to produce HTML → store it.&lt;/li&gt;
&lt;li&gt;After → hit → &lt;strong&gt;return the stored HTML directly, ERB doesn't run&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;courier modified → key changes → only that chunk re-renders.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Russian Doll caching&lt;/strong&gt;: nested — an outer cache wrapping inner caches, like a matryoshka:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight erb"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt; &lt;span class="n"&gt;courier&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;                  &lt;span class="c"&gt;&amp;lt;%# outer: courier card %&amp;gt;&lt;/span&gt;
  &lt;span class="nt"&gt;&amp;lt;h3&amp;gt;&lt;/span&gt;&lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;courier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;&lt;span class="nt"&gt;&amp;lt;/h3&amp;gt;&lt;/span&gt;
  &lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="n"&gt;courier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shipments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;shipment&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
    &lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="n"&gt;cache&lt;/span&gt; &lt;span class="n"&gt;shipment&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;             &lt;span class="c"&gt;&amp;lt;%# inner: each shipment %&amp;gt;&lt;/span&gt;
      &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;render&lt;/span&gt; &lt;span class="n"&gt;shipment&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
    &lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
  &lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The clever part is "change one, re-render only one." Change a shipment → only that inner card re-renders; the outer courier card also reassembles, but &lt;strong&gt;the unchanged shipments' inner cards come straight from cache&lt;/strong&gt;, so reassembly is just stitching existing HTML — fast.&lt;/p&gt;

&lt;p&gt;For "change a shipment → outer courier updates too" to work, the inner change must propagate up:&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;Shipment&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;belongs_to&lt;/span&gt; &lt;span class="ss"&gt;:courier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;touch: &lt;/span&gt;&lt;span class="kp"&gt;true&lt;/span&gt;   &lt;span class="c1"&gt;# a shipment change also touches the courier's updated_at&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;touch: true&lt;/code&gt; makes the inner change bump the outer key, which is what makes the whole Russian Doll work.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Why is &lt;code&gt;touch&lt;/code&gt; needed? Because an outer cache &lt;em&gt;hit&lt;/em&gt; returns the whole stored HTML wholesale — it never re-checks the inner caches. So if the outer key doesn't change, you'd serve stale inner content. &lt;code&gt;touch&lt;/code&gt; forces the outer to miss and reassemble (pulling unchanged inners from cache).&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  🗄️ Where the cache lives (cache store)
&lt;/h2&gt;

&lt;p&gt;Where &lt;code&gt;Rails.cache&lt;/code&gt; stores things is decided by the cache store, and the production choice matters:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;store&lt;/th&gt;
&lt;th&gt;where&lt;/th&gt;
&lt;th&gt;use&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;:file_store&lt;/code&gt; (the default when unset)&lt;/td&gt;
&lt;td&gt;local disk files&lt;/td&gt;
&lt;td&gt;single machine; not shared across machines&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:memory_store&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;a single process's memory&lt;/td&gt;
&lt;td&gt;lost on restart, not shared across workers — &lt;strong&gt;don't use in production&lt;/strong&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:redis_cache_store&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Redis&lt;/td&gt;
&lt;td&gt;⭐ the production mainstream, shared across servers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:mem_cache_store&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Memcached&lt;/td&gt;
&lt;td&gt;pure-cache scenarios&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;:solid_cache_store&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;the database (default in new Rails 8 apps)&lt;/td&gt;
&lt;td&gt;when you don't want to run a separate Redis&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The key point&lt;/strong&gt;: production usually has &lt;strong&gt;multiple servers / workers&lt;/strong&gt;. With &lt;code&gt;memory_store&lt;/code&gt;, the cache server A computed isn't visible to server B — effectively not shared. So use &lt;strong&gt;Redis / Memcached, a centralized store&lt;/strong&gt;, so the whole system shares one cache.&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;# config/environments/production.rb&lt;/span&gt;
&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;cache_store&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="ss"&gt;:redis_cache_store&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;url: &lt;/span&gt;&lt;span class="no"&gt;ENV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"REDIS_URL"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  🌐 Layer 4 (transfer): HTTP caching (ETag)
&lt;/h2&gt;

&lt;p&gt;The earlier layers save "recompute," but the server still assembles and sends the response. HTTP caching goes further — when data hasn't changed, &lt;strong&gt;don't even send it&lt;/strong&gt;:&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;def&lt;/span&gt; &lt;span class="nf"&gt;show&lt;/span&gt;
  &lt;span class="vi"&gt;@courier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Courier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;find&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="ss"&gt;:id&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
  &lt;span class="n"&gt;fresh_when&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="vi"&gt;@courier&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# computes an ETag (version fingerprint) from updated_at&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;On response, the server includes an &lt;strong&gt;ETag&lt;/strong&gt; header; the browser remembers it.&lt;/li&gt;
&lt;li&gt;On the next request, the browser automatically sends "I have this version."&lt;/li&gt;
&lt;li&gt;The server compares: unchanged → returns &lt;strong&gt;&lt;code&gt;304 Not Modified&lt;/code&gt;&lt;/strong&gt; with an &lt;strong&gt;empty body&lt;/strong&gt;, the browser reuses its own copy; changed → normal &lt;code&gt;200&lt;/code&gt; + new content.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What it saves is &lt;strong&gt;bandwidth and transfer&lt;/strong&gt; — when nothing changed, just a tiny 304 instead of re-sending the whole page.&lt;/p&gt;




&lt;h2&gt;
  
  
  🏁 Wrap-up: the four layers of caching
&lt;/h2&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;tool&lt;/th&gt;
&lt;th&gt;what it saves&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;compute&lt;/td&gt;
&lt;td&gt;&lt;code&gt;Rails.cache.fetch&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;recomputing expensive work&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;invalidate&lt;/td&gt;
&lt;td&gt;key-based (&lt;code&gt;cache_key_with_version&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;the pain of manual cache clearing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;render&lt;/td&gt;
&lt;td&gt;fragment / Russian Doll&lt;/td&gt;
&lt;td&gt;re-rendering HTML&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;transfer&lt;/td&gt;
&lt;td&gt;HTTP caching (ETag)&lt;/td&gt;
&lt;td&gt;even the send (304)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;All four share one underlying idea:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Use &lt;code&gt;updated_at&lt;/code&gt; as a "version" — reuse while data is unchanged, auto-update when it changes.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The biggest trap in caching isn't "how to store," it's "&lt;strong&gt;how to keep it from going stale&lt;/strong&gt;." Remember key-based — don't clear caches by hand, let the key follow the data's version — and that infamous cache-invalidation problem is mostly solved. But don't swing the other way and cache everything either: first confirm the thing is genuinely "expensive + reused," then cache it, or you've just added complexity and a "why am I seeing stale data" debugging nightmare.&lt;/p&gt;




&lt;blockquote&gt;
&lt;p&gt;📌 &lt;strong&gt;This post is about how caching &lt;em&gt;works&lt;/em&gt;.&lt;/strong&gt; But caching's real difficulty is in the failure modes you hit &lt;em&gt;after&lt;/em&gt; it ships — hammering the DB the instant a key expires (stampede is just the appetizer), a whole code path breaking when the cache disappears, an unbounded key set blowing up memory, even faithfully storing an error. Those "you only learn it by getting burned" traps are in the follow-up: &lt;strong&gt;&lt;a href="https://dev.to/danewu/we-added-a-cache-three-days-later-it-took-the-database-down-at-peak-p46"&gt;We added a cache; three days later it took the database down at peak&lt;/a&gt;&lt;/strong&gt;.&lt;/p&gt;
&lt;/blockquote&gt;

</description>
      <category>rails</category>
      <category>performance</category>
      <category>caching</category>
      <category>redis</category>
    </item>
    <item>
      <title>ActiveRecord: You Wanted a Number, but Loaded 500,000 Rows Into Memory</title>
      <dc:creator>Dane Wu</dc:creator>
      <pubDate>Fri, 26 Jun 2026 01:18:02 +0000</pubDate>
      <link>https://dev.to/danewu/you-wanted-a-number-but-loaded-500000-rows-into-memory-5087</link>
      <guid>https://dev.to/danewu/you-wanted-a-number-but-loaded-500000-rows-into-memory-5087</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Rails Performance: Lessons from Production — #3&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The first two posts were about making queries cheaper — fewer N+1s, indexes, not dragging data back into Ruby. This one turns that into a more practical habit: &lt;strong&gt;let the database do what the database can do.&lt;/strong&gt; A &lt;code&gt;.count&lt;/code&gt; in the wrong place, a misused &lt;code&gt;.present?&lt;/code&gt;, and you can pull an entire table into memory just to get a single number. Same example throughout (a &lt;code&gt;shipments&lt;/code&gt; table).&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  💥 A report page that ate the server's memory
&lt;/h2&gt;

&lt;p&gt;We had an admin report showing, per courier, the number of &lt;em&gt;delivered&lt;/em&gt; shipments. It was fine — until someone tweaked the filtering logic. After that, opening the page spiked memory, and under traffic it OOM-killed the server.&lt;/p&gt;

&lt;p&gt;The culprit was this line:&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="n"&gt;courier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shipments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;select&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s2"&gt;"delivered"&lt;/span&gt; &lt;span class="p"&gt;}.&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Looks reasonable — "it's just &lt;code&gt;select&lt;/code&gt;." But this &lt;code&gt;.select { ... }&lt;/code&gt; (with a block) is &lt;strong&gt;Ruby's Array method, not ActiveRecord's &lt;code&gt;select(:col)&lt;/code&gt;&lt;/strong&gt;. It loads &lt;em&gt;all&lt;/em&gt; of that courier's shipments into memory first, then filters them one by one in Ruby. Fine on a small table; on a big one it eats all the RAM.&lt;/p&gt;

&lt;p&gt;One word, &lt;code&gt;select&lt;/code&gt;, two completely different meanings — one builds SQL, the other loads everything and filters in Ruby. It's one of the easiest traps to fall into and the hardest to spot. The fix is to let the DB filter and count:&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="n"&gt;courier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shipments&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="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"delivered"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;
&lt;span class="c1"&gt;# → SELECT COUNT(*) FROM shipments WHERE courier_id = ? AND status = 'delivered'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The DB filters, counts, and returns a single number. The memory problem vanishes.&lt;/p&gt;

&lt;p&gt;Behind this is a recurring pattern: &lt;strong&gt;for a single number, boolean, or sum, you drag a whole batch of rows back into Ruby.&lt;/strong&gt; This post walks through its common variants.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Continuing the four layers from &lt;a href="https://dev.to/danewu/from-8s-to-1s-truly-understanding-rails-n1-by-opening-up-activerecord-1oj"&gt;series #1&lt;/a&gt;: moving data from ③ the DB back to ④ memory has a cost. The whole theme is — &lt;strong&gt;don't move it if you don't have to, and if you must, move less.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  🔢 Counting: &lt;code&gt;count&lt;/code&gt; vs &lt;code&gt;size&lt;/code&gt; vs &lt;code&gt;length&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;All three "count things," but they work differently — pick wrong and you hit the trap above.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;count&lt;/code&gt;&lt;/strong&gt; — always sends &lt;code&gt;SELECT COUNT(*)&lt;/code&gt; to the DB and returns a number.&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;Shipment&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="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"delivered"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;
&lt;span class="c1"&gt;# → SELECT COUNT(*) FROM shipments WHERE status = 'delivered'   always queries the DB&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Downside: even if the records are already loaded, it still &lt;strong&gt;queries the DB again&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;length&lt;/code&gt;&lt;/strong&gt; — can only count an in-memory array, so it &lt;strong&gt;force-loads everything&lt;/strong&gt; then counts.&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="n"&gt;shipments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Shipment&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="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"delivered"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;shipments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;length&lt;/span&gt;
&lt;span class="c1"&gt;# → SELECT * FROM shipments WHERE status = 'delivered'   pulls the whole batch into memory, then counts the array&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Downside: if not loaded, it drags the whole batch into memory (the opening trap).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;size&lt;/code&gt;&lt;/strong&gt; — picks based on state: not loaded → acts like &lt;code&gt;count&lt;/code&gt;; already loaded → acts like &lt;code&gt;length&lt;/code&gt;. &lt;strong&gt;Always the cheapest option.&lt;/strong&gt;&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="n"&gt;shipments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Shipment&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="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"delivered"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;shipments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt;      &lt;span class="c1"&gt;# not loaded → runs count, SELECT COUNT(*), no rows moved&lt;/span&gt;
&lt;span class="n"&gt;shipments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;to_a&lt;/span&gt;      &lt;span class="c1"&gt;# (this loads the data into memory)&lt;/span&gt;
&lt;span class="n"&gt;shipments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt;      &lt;span class="c1"&gt;# loaded → counts the in-memory array, no DB query&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&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;Behavior&lt;/th&gt;
&lt;th&gt;Risk&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;count&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;always queries the DB&lt;/td&gt;
&lt;td&gt;re-queries even when loaded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;length&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;always uses memory&lt;/td&gt;
&lt;td&gt;drags the whole batch in when not loaded&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;size&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;not loaded → DB, loaded → memory&lt;/td&gt;
&lt;td&gt;almost none, safest&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;How to choose&lt;/strong&gt;: only want a number, won't use the rows → &lt;code&gt;count&lt;/code&gt;; data already (or about to be) loaded and you count while using it → &lt;code&gt;size&lt;/code&gt;; &lt;code&gt;length&lt;/code&gt; almost never.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This also explains why &lt;a href="https://dev.to/danewu/from-8s-to-1s-truly-understanding-rails-n1-by-opening-up-activerecord-1oj"&gt;series #1&lt;/a&gt; used &lt;code&gt;c.shipments.size&lt;/code&gt; in the view, not &lt;code&gt;.count&lt;/code&gt; — shipments were preloaded into memory, so &lt;code&gt;size&lt;/code&gt; counts memory; &lt;code&gt;.count&lt;/code&gt; would re-query the DB per courier, bringing the N+1 right back.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  ❓ Existence: &lt;code&gt;exists?&lt;/code&gt; vs &lt;code&gt;present?&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;You want to know whether a courier has any delivered shipments — just true / false.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;❌ &lt;code&gt;present?&lt;/code&gt; / &lt;code&gt;any?&lt;/code&gt;&lt;/strong&gt; — loads the matching rows into memory first, then checks if the array is empty:&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="n"&gt;courier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shipments&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="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"delivered"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;present?&lt;/span&gt;
&lt;span class="c1"&gt;# → SELECT * FROM shipments WHERE courier_id = 1 AND status = 'delivered'&lt;/span&gt;
&lt;span class="c1"&gt;#   (loads all matches, just to decide "any?")&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;✅ &lt;code&gt;exists?&lt;/code&gt;&lt;/strong&gt; — asks the DB and stops at the first hit:&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="n"&gt;courier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shipments&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="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"delivered"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;exists?&lt;/span&gt;
&lt;span class="c1"&gt;# → SELECT 1 FROM shipments WHERE courier_id = 1 AND status = 'delivered' LIMIT 1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;LIMIT 1&lt;/code&gt; is the key: the DB returns "yes" on the first match — no full scan, no rows moved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exception&lt;/strong&gt;: if you'll &lt;strong&gt;use those rows right after&lt;/strong&gt; the check, load them first with &lt;code&gt;.to_a&lt;/code&gt; and use &lt;code&gt;present?&lt;/code&gt; — the load isn't wasted:&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="n"&gt;shipments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;courier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shipments&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="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"delivered"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to_a&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;shipments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;present?&lt;/span&gt;   &lt;span class="c1"&gt;# ✅ present? is right here, the data is already needed&lt;/span&gt;
  &lt;span class="n"&gt;shipments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;...&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In one line: just checking existence, won't use the rows → &lt;code&gt;exists?&lt;/code&gt;; using the rows right after → load first, then &lt;code&gt;present?&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  ➕ Aggregation: sum, average, group — hand them to the DB
&lt;/h2&gt;

&lt;p&gt;Compute "total weight of all delivered shipments."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;❌ Pull rows back and loop in Ruby&lt;/strong&gt;:&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;Shipment&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="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"delivered"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;weight&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;   &lt;span class="c1"&gt;# block = Ruby method&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;sum&lt;/code&gt; &lt;em&gt;with a block&lt;/em&gt; is &lt;strong&gt;Ruby's Array method&lt;/strong&gt; — it loads 500k objects into memory, then loops and adds them one by one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;✅ Let the DB sum&lt;/strong&gt;:&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;Shipment&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="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"delivered"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:weight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# column name = ActiveRecord method&lt;/span&gt;
&lt;span class="c1"&gt;# → SELECT SUM(weight) FROM shipments WHERE status = 'delivered'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The difference is the argument&lt;/strong&gt;: &lt;code&gt;sum(:weight)&lt;/code&gt; passes a column symbol → builds SQL for the DB ✅; &lt;code&gt;sum { |s| s.weight }&lt;/code&gt; passes a block → loads then loops in Ruby ❌.&lt;/p&gt;

&lt;p&gt;Other aggregates work the same — ActiveRecord wraps them:&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;Shipment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;average&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:weight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# AVG&lt;/span&gt;
&lt;span class="no"&gt;Shipment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;maximum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:weight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# MAX&lt;/span&gt;
&lt;span class="no"&gt;Shipment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;minimum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:weight&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# MIN&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Grouped counts&lt;/strong&gt; (how many each courier shipped) also go to the DB:&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;# ❌ pull everything, group in Ruby&lt;/span&gt;
&lt;span class="no"&gt;Shipment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;all&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group_by&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="ss"&gt;:courier_id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;transform_values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="ss"&gt;:count&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# ✅ DB groups &amp;amp; counts, returns a tiny result&lt;/span&gt;
&lt;span class="no"&gt;Shipment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;group&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:courier_id&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;count&lt;/span&gt;
&lt;span class="c1"&gt;# → SELECT courier_id, COUNT(*) FROM shipments GROUP BY courier_id&lt;/span&gt;
&lt;span class="c1"&gt;# → { 1 =&amp;gt; 1200, 2 =&amp;gt; 800, ... }&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Rule of thumb: &lt;strong&gt;check whether &lt;code&gt;sum&lt;/code&gt; / &lt;code&gt;count&lt;/code&gt; is given a column name or a block&lt;/strong&gt; — column name goes to the DB (right), block runs in Ruby (usually wrong).&lt;/p&gt;

&lt;p&gt;In one line: sum, average, count, group — operations that "collapse a pile of rows into one result" are exactly what the DB does, right next to the data. Pulling rows back to compute = hauling a truckload home just to read one number off it.&lt;/p&gt;




&lt;h2&gt;
  
  
  📦 When you really must: &lt;code&gt;find_each&lt;/code&gt; loads in batches
&lt;/h2&gt;

&lt;p&gt;Sometimes you genuinely have to pull every row back into Ruby (sending emails, generating reports, exporting). For that unavoidable case, the point shifts to: &lt;strong&gt;don't load all at once — load in batches.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;❌ &lt;code&gt;.each&lt;/code&gt;&lt;/strong&gt; — loads everything into memory at once:&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;Shipment&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="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"delivered"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="no"&gt;SendMailJob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform_later&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="c1"&gt;# → SELECT * ... pulls all 500k rows; every object in RAM at once → possible OOM&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;✅ &lt;code&gt;find_each&lt;/code&gt;&lt;/strong&gt; — fetches batch by batch under the hood (1000 by default), processing one batch before loading the next:&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;Shipment&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="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"delivered"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;find_each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="no"&gt;SendMailJob&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perform_later&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="c1"&gt;# → SELECT * ... ORDER BY id LIMIT 1000&lt;/span&gt;
&lt;span class="c1"&gt;#   SELECT * ... AND id &amp;gt; 1000 ORDER BY id LIMIT 1000&lt;/span&gt;
&lt;span class="c1"&gt;#   ...uses the last id of the previous batch as the next starting point&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;At any moment only 1000 rows are in memory&lt;/strong&gt; — 500k won't blow up. Batch size is tunable: &lt;code&gt;find_each(batch_size: 500)&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For operating on a whole batch (bulk updates), use &lt;code&gt;in_batches&lt;/code&gt;:&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;Shipment&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="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"pending"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;in_batches&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="n"&gt;batch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"processing"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# update a whole batch at once, not row by row&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;






&lt;h2&gt;
  
  
  🏁 Wrap-up
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;You want&lt;/th&gt;
&lt;th&gt;❌ pull into Ruby&lt;/th&gt;
&lt;th&gt;✅ keep in the DB / batch&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;a count&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.to_a.count&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.count&lt;/code&gt; / &lt;code&gt;.size&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;existence&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.present?&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;.exists?&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sum / avg / group&lt;/td&gt;
&lt;td&gt;pull back and loop&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;sum(:column)&lt;/code&gt;, &lt;code&gt;group.count&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;process many rows&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;.each&lt;/code&gt; (load all)&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;find_each&lt;/code&gt; (batch)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;One principle runs through all of it:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Let the database do what the database can do; when you must pull into Ruby, do it in batches, not all at once.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is the same thing as &lt;a href="https://dev.to/danewu/from-8s-to-1s-truly-understanding-rails-n1-by-opening-up-activerecord-1oj"&gt;series #1&lt;/a&gt; — moving data into memory has a cost. That opening OOM wasn't caused by "too much data," but by "moving too much data back for a single number." Before you write &lt;code&gt;count&lt;/code&gt;, &lt;code&gt;sum&lt;/code&gt;, or &lt;code&gt;present?&lt;/code&gt;, ask: &lt;strong&gt;do I want a result, or do I actually need this whole batch of rows?&lt;/strong&gt; It's usually the former — so let the DB hand you the result.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>performance</category>
      <category>database</category>
      <category>ruby</category>
    </item>
    <item>
      <title>A 1000x Speedup From One Index — and Why It Sometimes Does Nothing</title>
      <dc:creator>Dane Wu</dc:creator>
      <pubDate>Thu, 25 Jun 2026 15:55:02 +0000</pubDate>
      <link>https://dev.to/danewu/a-1000x-speedup-from-one-index-and-why-it-sometimes-does-nothing-6if</link>
      <guid>https://dev.to/danewu/a-1000x-speedup-from-one-index-and-why-it-sometimes-does-nothing-6if</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Rails Performance: Lessons from Production — #2&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;"Slow query? Add an index" is something everyone has heard. But I once hit a more embarrassing situation: &lt;strong&gt;I added the index, and the query didn't get any faster.&lt;/strong&gt; Debugging that forced me to actually understand how indexes work — when they're lightning fast, and when they simply won't be used. This post walks through it with one example: a &lt;code&gt;shipments&lt;/code&gt; table with a few million rows.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  🐌 I added an index and the query was still slow
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;shipments&lt;/code&gt; table has 3 million rows. The front end looks up one row by tracking number: &lt;code&gt;Shipment.where(tracking_no: "ABC123")&lt;/code&gt; — 12 seconds slow. I added an index and it dropped to 8 milliseconds. One line of &lt;code&gt;CREATE INDEX&lt;/code&gt;, a thousand times faster. Felt great.&lt;/p&gt;

&lt;p&gt;Until another query, where I added an index the same way, and &lt;code&gt;EXPLAIN&lt;/code&gt; still showed a full table scan — &lt;strong&gt;no faster at all&lt;/strong&gt;. That's when it clicked: an index isn't magic that makes things fast just by existing. It has its own rules — and if you don't understand them, adding one does nothing.&lt;/p&gt;




&lt;h2&gt;
  
  
  📖 Why an index is fast: start from the full table scan
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Without an index&lt;/strong&gt;, the DB can only find a row by &lt;strong&gt;scanning from the first row to the last&lt;/strong&gt;, comparing &lt;code&gt;tracking_no&lt;/code&gt; one by one. With 3 million rows that's 1.5 million on average — like a book with no index, where finding a word means flipping through the whole thing.&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;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;shipments&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tracking_no&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ABC123'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- No index: Seq Scan, scans all 3 million rows&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;With an index&lt;/strong&gt;, it's like the index page at the back of a book: it keeps &lt;code&gt;tracking_no&lt;/code&gt; &lt;strong&gt;pre-sorted&lt;/strong&gt; in a separate copy, each value noting "where this row lives in the main table." The DB doesn't flip through the whole book — it locates the value directly in the sorted index, then jumps to that row.&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;index_shipments_on_tracking_no&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;shipments&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tracking_no&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One sentence: &lt;strong&gt;an index is fast because it's pre-sorted, so the DB can use "fast lookup on sorted data" instead of "scan every row."&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  🌳 Under the hood it's a B-tree (not a binary tree)
&lt;/h2&gt;

&lt;p&gt;Common confusion: an index's B-tree is not a binary tree. A binary tree has at most 2 branches per node; a B-tree has &lt;strong&gt;hundreds&lt;/strong&gt; of branches per node — a wide, shallow tree.&lt;/p&gt;

&lt;p&gt;Why wide and shallow? Because &lt;strong&gt;reading from disk is slow.&lt;/strong&gt; Every level the DB descends is one more disk read.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Binary tree: 3 million rows is ~21 levels deep → 21 disk reads.&lt;/li&gt;
&lt;li&gt;B-tree: each level fans out hundreds of times, so 3 million rows is only &lt;strong&gt;3–4 levels&lt;/strong&gt; → 3–4 disk reads.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the whole point of a B-tree is to minimize the number of disk reads. That's the physical reason an index is fast.&lt;/p&gt;




&lt;h2&gt;
  
  
  💰 Indexes aren't free, so you can't index every column
&lt;/h2&gt;

&lt;p&gt;An index is "an extra sorted copy of the data." Two costs:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Writes get slower&lt;/strong&gt; — every &lt;code&gt;INSERT&lt;/code&gt; / &lt;code&gt;UPDATE&lt;/code&gt; / &lt;code&gt;DELETE&lt;/code&gt; doesn't just change the main table, it &lt;strong&gt;has to maintain every index&lt;/strong&gt; (inserting the new value into the right spot in each B-tree). With 5 indexes, each write touches 6 places. For a table like &lt;code&gt;shipments&lt;/code&gt; that constantly takes new orders, that cost is very real.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Space&lt;/strong&gt; — an index really does store an extra copy on disk.&lt;/p&gt;

&lt;p&gt;So an index is fundamentally a &lt;strong&gt;trade-off: slower writes and more storage in exchange for faster reads.&lt;/strong&gt; The rule of thumb: &lt;strong&gt;only index a column that's actually queried (where / order / join).&lt;/strong&gt; Read-heavy, write-light columns pay off the most; on write-heavy tables, be sparing.&lt;/p&gt;




&lt;h2&gt;
  
  
  🎯 So when do you actually add one?
&lt;/h2&gt;

&lt;p&gt;Indexes come from two places:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The ones you add by design (predictable)&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Foreign keys&lt;/strong&gt;: &lt;code&gt;shipments.courier_id&lt;/code&gt; — you'll definitely &lt;code&gt;where(courier_id:)&lt;/code&gt; and join on it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unique lookup columns&lt;/strong&gt;: &lt;code&gt;tracking_no&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt;, &lt;code&gt;slug&lt;/code&gt; — the ones you use to pinpoint a single row.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Obviously high-frequency filter/sort columns&lt;/strong&gt;: ones you know at design time will be queried constantly.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;Rails note: in a migration, &lt;code&gt;t.references&lt;/code&gt; / &lt;code&gt;add_reference&lt;/code&gt; builds an index on the foreign-key column by default; &lt;code&gt;foreign_key: true&lt;/code&gt; adds a &lt;em&gt;data-integrity constraint&lt;/em&gt;, which is a separate thing from an index — don't conflate them.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;strong&gt;2. The ones you add after launch, driven by data (the important part)&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Don't speculatively add the rest — you can't guess which will be slow. The real workflow is "measure first, then index where needed":&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Find the slow query&lt;/strong&gt; from APM or the DB's slow query log.&lt;/li&gt;
&lt;li&gt;Use &lt;code&gt;EXPLAIN&lt;/code&gt; to confirm it's actually doing a full table scan (&lt;code&gt;Seq Scan&lt;/code&gt; in Postgres, &lt;code&gt;type: ALL&lt;/code&gt; in MySQL).&lt;/li&gt;
&lt;li&gt;Add an index on that column, then &lt;code&gt;EXPLAIN&lt;/code&gt; again to confirm it became an &lt;code&gt;Index Scan&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Confirm it's actually faster.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;One thing often overlooked: small tables don't need indexes.&lt;/strong&gt; A few thousand rows scans in milliseconds anyway; an index just adds write cost. The value of an index only shows up once the table is big enough.&lt;/p&gt;

&lt;p&gt;In short: &lt;strong&gt;add the few predictable ones by design; for the rest, index against actual slow queries and verify with EXPLAIN; leave small tables alone.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  🔑 Composite indexes and the leftmost prefix
&lt;/h2&gt;

&lt;p&gt;Real queries are often multi-condition: &lt;code&gt;where(courier_id: 1, status: "delivered")&lt;/code&gt;. For that, a single &lt;strong&gt;composite index&lt;/strong&gt; can cover multiple columns:&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;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;shipments&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;courier_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key is &lt;strong&gt;how it's sorted&lt;/strong&gt;: first by &lt;code&gt;courier_id&lt;/code&gt;, then by &lt;code&gt;status&lt;/code&gt; within the same &lt;code&gt;courier_id&lt;/code&gt;, then by &lt;code&gt;created_at&lt;/code&gt; within the same &lt;code&gt;status&lt;/code&gt;. Just like a phone book — sorted by last name, then first name within a last name.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The leftmost-prefix rule&lt;/strong&gt;: because it sorts starting from the left column, you can only use it &lt;strong&gt;contiguously from the left&lt;/strong&gt;.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;th&gt;Uses the index?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;where(courier_id: 1)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;where(courier_id: 1, status: "x")&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;✅&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;where(status: "delivered")&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;❌ skips the leftmost courier_id&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Why doesn't &lt;code&gt;where(status:)&lt;/code&gt; use it? The phone book is sorted by last name first. If you only know the first name "John" but not the last name, the Johns are scattered across every last name — not grouped by first name — so you still flip through the whole book. &lt;code&gt;status&lt;/code&gt; is scattered in the index the same way.&lt;/p&gt;

&lt;p&gt;So &lt;strong&gt;column order matters&lt;/strong&gt;. Two principles:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Put the column most often queried alone on the left&lt;/strong&gt; (so it works both alone and combined).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Equality (&lt;code&gt;=&lt;/code&gt;) before range (&lt;code&gt;&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;&lt;/code&gt;)&lt;/strong&gt;. A range "truncates" the index — &lt;code&gt;created_at &amp;gt; x&lt;/code&gt; spans a big stretch across many different &lt;code&gt;created_at&lt;/code&gt; values, and within that stretch the later columns are out of order, so the index can't continue.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  ⚠️ Why an index sometimes doesn't kick in (back to the opening trap)
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;EXPLAIN&lt;/code&gt; still shows a full table scan even though you added the index — usually one of these, all sharing one cause: &lt;strong&gt;they defeat the index's sort order.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. Applying a function or operation to the column&lt;/strong&gt;&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="n"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"DATE(created_at) = ?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"2026-06-25"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# ❌ DB must compute DATE() per row to compare&lt;/span&gt;
&lt;span class="n"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;created_at: &lt;/span&gt;&lt;span class="no"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"2026-06-25"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;all_day&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# ✅ use a range instead — touch the value, not the column&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The index sorts by the column's raw value; the moment you compute on the column, that sort order is useless.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;LIKE '%xxx'&lt;/code&gt; with a leading wildcard&lt;/strong&gt;&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="n"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"tracking_no LIKE ?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"ABC%"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# ✅ knows the prefix, can locate&lt;/span&gt;
&lt;span class="n"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"tracking_no LIKE ?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"%123"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# ❌ no prefix, scans everything&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Type mismatch&lt;/strong&gt; — &lt;code&gt;tracking_no&lt;/code&gt; is a string column but you pass a number:&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="n"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;tracking_no: &lt;/span&gt;&lt;span class="mi"&gt;123&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;# ❌ DB casts every row's column to a number to compare = operating on the column → index defeated&lt;/span&gt;
&lt;span class="n"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;tracking_no: &lt;/span&gt;&lt;span class="s2"&gt;"123"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# ✅ types match, uses the index directly&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;4. The condition matches too large a fraction&lt;/strong&gt; — if a condition hits a big chunk of the table, the DB &lt;strong&gt;deliberately skips the index&lt;/strong&gt;, because "locate + repeatedly jump back to the main table" ends up slower than just scanning once:&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="n"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"active"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;    &lt;span class="c1"&gt;# ❌ if 90% of shipments are active → DB prefers a full scan&lt;/span&gt;
&lt;span class="n"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"returned"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# ✅ only 1%, a small slice → the index pays off&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Indexes are for grabbing a small slice, not a big chunk. So a column like &lt;code&gt;status&lt;/code&gt; with only a few, unevenly distributed values often can't use an index.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;5. &lt;code&gt;OR&lt;/code&gt; across different columns&lt;/strong&gt; — &lt;code&gt;courier_id&lt;/code&gt; and &lt;code&gt;tracking_no&lt;/code&gt; each have an index, but a single &lt;code&gt;OR&lt;/code&gt; query struggles to use both at once and often degrades to a full scan. Splitting into two queries, each using its own index, then merging, is faster:&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;# ❌ one OR, hard to use both indexes&lt;/span&gt;
&lt;span class="no"&gt;Shipment&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;"courier_id = ? OR tracking_no = ?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"ABC123"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# ✅ split into two, each uses its own index, then merge + dedupe&lt;/span&gt;
&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Shipment&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="ss"&gt;courier_id: &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Shipment&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="ss"&gt;tracking_no: &lt;/span&gt;&lt;span class="s2"&gt;"ABC123"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;uniq&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- the SQL equivalent: UNION two queries that each use their index&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;shipments&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;courier_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;UNION&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;shipments&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tracking_no&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'ABC123'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The shared principle: an index works off the sort order of the column's raw value. Anything that makes the DB "compute/cast the column per row" or "lose the ability to locate by prefix" defeats it. So — don't touch the column, touch the value side instead.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  🏁 Wrap-up
&lt;/h2&gt;

&lt;p&gt;An index isn't a switch that makes things fast just by flipping it. It's fast because it's pre-sorted and the B-tree minimizes disk reads; it has a cost, so you only index columns that get queried; and it has rules — a composite index is used contiguously from the left, and operating on a column defeats it.&lt;/p&gt;

&lt;p&gt;That opening query that "had an index but wasn't faster" turned out to have a function wrapped around the column. The question was never "should I add an index" — it's &lt;strong&gt;whether you understand how it sorts and when it gets used.&lt;/strong&gt; Next time a query is slow, don't rush to &lt;code&gt;CREATE INDEX&lt;/code&gt; — run &lt;code&gt;EXPLAIN&lt;/code&gt; first and see what it's actually doing. The answer is usually right there.&lt;/p&gt;




&lt;h2&gt;
  
  
  🧩 Appendix: &lt;code&gt;(a + b).uniq&lt;/code&gt; and UNION / DISTINCT
&lt;/h2&gt;

&lt;p&gt;About that &lt;code&gt;(a + b).uniq&lt;/code&gt; from the OR-split above, in three parts:&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="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Shipment&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="ss"&gt;courier_id: &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;          &lt;span class="c1"&gt;# query 1&lt;/span&gt;
&lt;span class="n"&gt;b&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Shipment&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="ss"&gt;tracking_no: &lt;/span&gt;&lt;span class="s2"&gt;"ABC123"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# query 2&lt;/span&gt;
&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;uniq&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;① What &lt;code&gt;a + b&lt;/code&gt; does&lt;/strong&gt; — runs both queries, &lt;strong&gt;fetches each, and concatenates them into one array&lt;/strong&gt; (not one combined SQL statement).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;② What &lt;code&gt;.uniq&lt;/code&gt; does&lt;/strong&gt; — dedupes. A shipment might satisfy both conditions and appear on both sides, so concatenating creates duplicates; &lt;code&gt;.uniq&lt;/code&gt; folds them into one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;③ Why &lt;code&gt;+&lt;/code&gt; hits the DB&lt;/strong&gt; — because &lt;code&gt;a&lt;/code&gt; and &lt;code&gt;b&lt;/code&gt; aren't arrays yet; they're "not-yet-executed queries" (&lt;code&gt;ActiveRecord::Relation&lt;/code&gt;), lazy. (Back to the four layers from #1: &lt;code&gt;.where&lt;/code&gt; only accumulates conditions, it hasn't touched the DB.) &lt;code&gt;+&lt;/code&gt; is an &lt;strong&gt;Array&lt;/strong&gt; method — it needs arrays, not relations — so it forces both to execute and become arrays before adding:&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="n"&gt;a&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;b&lt;/span&gt;
&lt;span class="c1"&gt;#  → a.to_a fires SELECT ... WHERE courier_id = 1&lt;/span&gt;
&lt;span class="c1"&gt;#  → b.to_a fires SELECT ... WHERE tracking_no = 'ABC123'&lt;/span&gt;
&lt;span class="c1"&gt;#  → then concatenates the two arrays&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So hitting the DB isn't done by &lt;code&gt;+&lt;/code&gt; — it's a side effect of &lt;code&gt;+&lt;/code&gt; forcing the relations to execute so it can build the array.&lt;/p&gt;

&lt;p&gt;The whole &lt;code&gt;(a + b).uniq&lt;/code&gt; is just simulating SQL's &lt;code&gt;UNION&lt;/code&gt; in Ruby: two separate queries, merged, deduped.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Aside: UNION vs DISTINCT&lt;/strong&gt; — both dedupe; the difference is "whose duplicates":&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;What it does&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;DISTINCT&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;dedupes &lt;strong&gt;within one result set&lt;/strong&gt; (one query)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UNION&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;merges &lt;strong&gt;two queries&lt;/strong&gt; + dedupes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;UNION ALL&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;merges two queries but does &lt;strong&gt;not&lt;/strong&gt; dedupe (faster)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

</description>
      <category>rails</category>
      <category>database</category>
      <category>sql</category>
      <category>performance</category>
    </item>
    <item>
      <title>Ruby on Rails activerecord</title>
      <dc:creator>Dane Wu</dc:creator>
      <pubDate>Thu, 25 Jun 2026 13:16:55 +0000</pubDate>
      <link>https://dev.to/danewu/ruby-on-rails-activerecord-3a6i</link>
      <guid>https://dev.to/danewu/ruby-on-rails-activerecord-3a6i</guid>
      <description></description>
    </item>
    <item>
      <title>From 8s to 1s: Truly Understanding Rails N+1 by Opening Up ActiveRecord</title>
      <dc:creator>Dane Wu</dc:creator>
      <pubDate>Thu, 25 Jun 2026 09:59:05 +0000</pubDate>
      <link>https://dev.to/danewu/from-8s-to-1s-truly-understanding-rails-n1-by-opening-up-activerecord-1oj</link>
      <guid>https://dev.to/danewu/from-8s-to-1s-truly-understanding-rails-n1-by-opening-up-activerecord-1oj</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Rails Performance: Lessons from Production — #1&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Most people fix N+1 by "adding &lt;code&gt;includes&lt;/code&gt;." But &lt;code&gt;includes&lt;/code&gt; isn't a silver bullet — I've hit a page that already had &lt;code&gt;includes&lt;/code&gt;, that Bullet never flagged, and that was &lt;em&gt;still&lt;/em&gt; N+1. That forced me to actually understand what ActiveRecord does underneath, instead of memorizing a few rules. This post takes it apart one layer at a time, using one example throughout (&lt;code&gt;Courier has_many :shipments&lt;/code&gt;), with the actual SQL at every step.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  🐢 I added &lt;code&gt;includes&lt;/code&gt; and the page was &lt;em&gt;still&lt;/em&gt; N+1
&lt;/h2&gt;

&lt;p&gt;We had an admin page listing couriers, each row showing how many shipments that courier had. As data grew, the page went from 1 second to 7–8 seconds.&lt;/p&gt;

&lt;p&gt;I opened the APM (New Relic): SQL alone took 6+ seconds, and the query count had exploded — one page listing 200 couriers fired 200+ queries. Classic &lt;strong&gt;N+1&lt;/strong&gt;. I added &lt;code&gt;includes(:shipments)&lt;/code&gt;, the query count dropped to 2, the page came back under 1 second, and I installed the Bullet gem in dev to catch N+1 automatically. Problem solved, I thought.&lt;/p&gt;

&lt;p&gt;Until &lt;strong&gt;another page — which already had &lt;code&gt;includes&lt;/code&gt;, and which Bullet never warned about — was still slow, still N+1.&lt;/strong&gt; Debugging that one taught me the real lesson: whether &lt;code&gt;includes&lt;/code&gt; works depends on &lt;strong&gt;how you use&lt;/strong&gt; the data it preloads, and you can only see that if you understand what ActiveRecord does underneath.&lt;/p&gt;

&lt;p&gt;So this post doesn't start with "add &lt;code&gt;includes&lt;/code&gt;." It starts one level deeper.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Same example throughout: &lt;code&gt;Courier has_many :shipments&lt;/code&gt;. Every step shows the &lt;strong&gt;actual SQL&lt;/strong&gt; that gets sent.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  🧱 Layer 0: every query passes through four stages
&lt;/h2&gt;

&lt;p&gt;Every line of ActiveRecord you write runs through this pipeline:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;① ActiveRecord syntax (Ruby)
     Courier.where(...).includes(...)
     just "accumulating conditions", lazy — hasn't touched the DB yet
         ↓ Rails translates
② SQL (a string)
     SELECT ... FROM couriers WHERE ...
     your conditions turned into something the DB understands
         ↓ sent
③ Database executes
     where the real work happens: JOIN, WHERE filter, ORDER, DISTINCT
     all of it here → returns a flat table of rows
         ↓ returned
④ Memory (Ruby objects)
     Rails turns each row back into an ActiveRecord object for you
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Remember two sentences — everything below is a corollary of them:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Filtering, JOINs, sorting, deduping all happen in ③ the database.&lt;/strong&gt; And the DB can only filter on columns that exist &lt;em&gt;in that SQL statement&lt;/em&gt; — a column that isn't in the query can't be filtered on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Objects, &lt;code&gt;.size&lt;/code&gt;, association arrays all happen in ④ memory.&lt;/strong&gt; The data is already back; nothing here touches the DB.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;ActiveRecord's selling point is hiding ②③ so it feels like there's only ① and ④. Great most of the time — but every performance problem lives in the ②③ you stopped looking at.&lt;/p&gt;




&lt;h2&gt;
  
  
  🔁 Layer 1: what N+1 actually is
&lt;/h2&gt;

&lt;p&gt;Let's grab three rows with &lt;code&gt;limit(3)&lt;/code&gt;. The naive version (no preloading):&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="n"&gt;couriers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Courier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;couriers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
  &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shipments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt;   &lt;span class="c1"&gt;# every iteration fires another query against ③&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The actual SQL sent:&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;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;couriers&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                  &lt;span class="c1"&gt;-- the "1": fetch 3 couriers&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;shipments&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;courier_id&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="c1"&gt;-- courier #1&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;shipments&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;courier_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;-- courier #2&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;shipments&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;courier_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;    &lt;span class="c1"&gt;-- courier #3&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fetching 3 couriers is 1 query; each iteration adds 1 more for its shipments: &lt;code&gt;1 + 3 = 4&lt;/code&gt;. With 200 couriers, that's 201.&lt;/p&gt;

&lt;p&gt;Why? Because &lt;code&gt;c.shipments&lt;/code&gt; is &lt;strong&gt;lazy&lt;/strong&gt; by default — touch it and it fires a fresh query against ③. Put that inside a loop and you get N queries.&lt;/p&gt;




&lt;h2&gt;
  
  
  📦 Layer 2: three ways to preload, collapsing those N queries
&lt;/h2&gt;

&lt;p&gt;The only difference is "how many SQL statements ② produces, and whether ③ does a JOIN." Same three rows:&lt;/p&gt;

&lt;h3&gt;
  
  
  preload — fetch separately (no JOIN)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;Courier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:shipments&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;couriers&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;                        &lt;span class="c1"&gt;-- parent table&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;shipments&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;courier_id&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;-- the N queries collapsed into one IN&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Afterwards, Rails matches the shipments back to their courier &lt;em&gt;in ④ memory&lt;/em&gt; using &lt;code&gt;courier_id&lt;/code&gt;. Always &lt;strong&gt;2 queries&lt;/strong&gt;.&lt;/p&gt;




&lt;h3&gt;
  
  
  eager_load — one query with a LEFT JOIN
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;Courier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eager_load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:shipments&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;couriers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;   &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;t0_r0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;couriers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;t0_r1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;shipments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;  &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;t1_r0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;shipments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tracking_no&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;t1_r1&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;couriers&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;shipments&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;shipments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;courier_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;couriers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One SQL statement, joining into a wide table. The columns get renamed to &lt;code&gt;t0_*&lt;/code&gt; / &lt;code&gt;t1_*&lt;/code&gt; because once the tables are joined, &lt;code&gt;id&lt;/code&gt; and &lt;code&gt;name&lt;/code&gt; collide — Rails numbers each column so it can split the row back into separate objects.&lt;/p&gt;




&lt;h3&gt;
  
  
  includes — let Rails choose
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# A: no filtering on the association → runs as preload (the 2 queries above)&lt;/span&gt;
&lt;span class="no"&gt;Courier&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;:shipments&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# B: filtering on a shipments column → auto-switches to eager_load (the JOIN above)&lt;/span&gt;
&lt;span class="no"&gt;Courier&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;:shipments&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="ss"&gt;shipments: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"delivered"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
       &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;references&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:shipments&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;includes&lt;/code&gt; isn't a third fetching strategy — it &lt;strong&gt;auto-switches&lt;/strong&gt;: no association filtering → preload; filtering on an association column (add &lt;code&gt;references&lt;/code&gt;) → eager_load (JOIN). &lt;strong&gt;Use it by default&lt;/strong&gt; and let Rails decide.&lt;/p&gt;

&lt;p&gt;So now the N queries are collapsed to a fixed count. But real pages aren't that simple — which is the payoff for that "includes still N+1" teaser. Here's the core.&lt;/p&gt;




&lt;h1&gt;
  
  
  🔀 Part 2 — After preloading: are you filtering the &lt;em&gt;child&lt;/em&gt; or the &lt;em&gt;parent&lt;/em&gt;?
&lt;/h1&gt;

&lt;p&gt;Real pages rarely "fetch everything and show it" — they have conditions. So first answer one question, because &lt;strong&gt;it decides which solution you use&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Filtering the child&lt;/strong&gt; — every courier should only &lt;em&gt;display&lt;/em&gt; a subset of its shipments (e.g. only delivered ones).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Filtering the parent&lt;/strong&gt; — you only want to &lt;em&gt;keep&lt;/em&gt; couriers matching a condition (e.g. only couriers that have a delivered shipment).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are two different needs with two different solutions. Conflating them is exactly how you write the "added includes but still N+1" code. Let's split them.&lt;/p&gt;




&lt;h2&gt;
  
  
  🍃 Need A: filter the child — each courier shows only its delivered shipments
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Keep every courier, but each one only displays its "delivered" shipments.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  ❌ The naive way: &lt;code&gt;.where&lt;/code&gt; on the association in the view (this is the bug from the opening)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# controller: preloads ALL shipments&lt;/span&gt;
&lt;span class="n"&gt;couriers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Courier&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;:shipments&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight erb"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;%# view: but each courier only wants the delivered ones %&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="n"&gt;couriers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
  &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shipments&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="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"delivered"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;   &lt;span class="c"&gt;&amp;lt;%# ← blows up here %&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The actual SQL:&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="c1"&gt;-- includes preloads ALL shipments first (these 2 are fine on their own)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;couriers&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;shipments&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;courier_id&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- but .where(status:) in the view is a NEW conditional query; Rails ignores the in-memory set&lt;/span&gt;
&lt;span class="c1"&gt;-- and re-queries once per courier:&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;shipments&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;courier_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'delivered'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;shipments&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;courier_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'delivered'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;shipments&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;courier_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'delivered'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Double waste&lt;/strong&gt;: you spent 2 queries loading all shipments into ④ memory (never used), then &lt;code&gt;.where&lt;/code&gt; re-queries once per courier → N+1 is fully back. And Bullet stays silent, because &lt;code&gt;:shipments&lt;/code&gt; &lt;em&gt;was&lt;/em&gt; preloaded — it only checks "did you preload," not "did you actually use what you preloaded."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Root cause (back to Layer 0)&lt;/strong&gt;: it's the nature of &lt;code&gt;.where&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In ActiveRecord, &lt;code&gt;.where&lt;/code&gt; is &lt;em&gt;always defined as&lt;/em&gt; "build a new SQL statement and send it to ③ the database." It is &lt;strong&gt;not&lt;/strong&gt; "filter the array in ④ memory." Two different layers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;What you assumed: this courier's shipments are already in memory, so &lt;code&gt;.where&lt;/code&gt; just picks the delivered ones from that array.&lt;/li&gt;
&lt;li&gt;What actually happens: the moment &lt;code&gt;.where&lt;/code&gt; is called, Rails builds a &lt;code&gt;WHERE ... AND status = 'delivered'&lt;/code&gt; and re-queries ③, &lt;strong&gt;completely ignoring the in-memory set&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Why doesn't it filter in memory? &lt;strong&gt;Timing.&lt;/strong&gt; The preload order is: ② builds and sends the "all shipments" SQL → ③ runs it → ④ loads it into memory. By the time you write &lt;code&gt;.where&lt;/code&gt; in the view, that preload SQL was built, sent, and returned long ago — &lt;strong&gt;the condition arrives too late to be slipped into a statement that already ran.&lt;/strong&gt; Rails can't go back and rewrite that SQL, so it builds a fresh one against the DB.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;To filter the in-memory set without hitting the DB, use Ruby's &lt;code&gt;select&lt;/code&gt; (not SQL's): &lt;code&gt;c.shipments.select { |s| s.status == "delivered" }&lt;/code&gt; — that genuinely filters the ④ array. But it still has to load everything first, which is rough on memory at scale. The clean fix is the scoped association below, making the condition take effect back at ②.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h3&gt;
  
  
  ✅ The fix: a scoped association (weld the condition into the association definition)
&lt;/h3&gt;

&lt;p&gt;Make the condition take effect &lt;strong&gt;at ② when the SQL is built&lt;/strong&gt;, not patched on at ④. Define an association that carries the condition:&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;Courier&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_many&lt;/span&gt; &lt;span class="ss"&gt;:shipments&lt;/span&gt;
  &lt;span class="c1"&gt;# scoped association: this association only ever contains delivered ones&lt;/span&gt;
  &lt;span class="n"&gt;has_many&lt;/span&gt; &lt;span class="ss"&gt;:delivered_shipments&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"delivered"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
           &lt;span class="ss"&gt;class_name: &lt;/span&gt;&lt;span class="s2"&gt;"Shipment"&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="c1"&gt;# controller&lt;/span&gt;
&lt;span class="n"&gt;couriers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Courier&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;:delivered_shipments&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight erb"&gt;&lt;code&gt;&lt;span class="c"&gt;&amp;lt;%# view: use it directly, no more .where %&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="n"&gt;couriers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
  &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;delivered_shipments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The actual SQL — back to a fixed 2 queries, with the condition merged into the preload's &lt;code&gt;IN&lt;/code&gt;:&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;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;couriers&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;shipments&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;courier_id&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'delivered'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The key difference&lt;/strong&gt;: the naive condition shows up at ④ memory (too late, doesn't match the preload); the scoped association makes the condition part of the association definition, so &lt;strong&gt;Rails sees it at ② when building the preload SQL and welds it straight into the same &lt;code&gt;IN&lt;/code&gt;&lt;/strong&gt; — ③ fetches exactly what's needed in one shot.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Analogy: sending someone to the warehouse. The naive way is "bring back everything," and only when it's in front of you do you say "I just wanted the delivered ones" → a wasted trip, and a second one to filter. The scoped association writes "only the delivered ones" on the list &lt;em&gt;before they leave&lt;/em&gt; → one correct trip.&lt;/p&gt;

&lt;p&gt;(Detail: an association scope must be wrapped in a lambda &lt;code&gt;-&amp;gt; { ... }&lt;/code&gt; — that's required Rails syntax. The lambda also means "run the condition on every query," which matters for time-based values like &lt;code&gt;7.days.ago&lt;/code&gt;: with the lambda it's recomputed each query as "7 days before today"; without it, the value is computed &lt;em&gt;once&lt;/em&gt; when the class loads and frozen at the server's boot date, so "last 7 days" silently drifts wrong as time passes. Our &lt;code&gt;status: "delivered"&lt;/code&gt; is a constant so it makes no difference here — but scopes are always written as lambdas regardless.)&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  🌳 Need B (advanced): filter the parent — keep only couriers that have a delivered shipment
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;List only couriers with at least one delivered shipment&lt;/strong&gt; — drop the rest entirely. Note how this differs from Need A: A keeps all couriers and filters their shipments; B filters the couriers themselves.&lt;/p&gt;

&lt;h3&gt;
  
  
  ⚠️ The naive way: eager_load + where (works, but "row explosion" on has_many)
&lt;/h3&gt;

&lt;p&gt;To filter the parent by a child column (&lt;code&gt;shipments.status&lt;/code&gt;), you &lt;em&gt;must&lt;/em&gt; JOIN — preload can't do it (Layer 0: filtering happens in ③, so the column has to be in that SQL). The obvious tool is &lt;code&gt;eager_load&lt;/code&gt;:&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;Courier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;eager_load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:shipments&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="ss"&gt;shipments: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"delivered"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;couriers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;shipments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;couriers&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;shipments&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;shipments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;courier_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;couriers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;shipments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'delivered'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Correct result, but a performance trap: &lt;strong&gt;eager_load also pulls the shipment columns back to build objects, and on a has_many the parent gets duplicated.&lt;/strong&gt; If a courier has 50 delivered shipments, that courier's columns appear in 50 rows:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;courier_id | name      | ... | shipment_id
1          | BlackCat  | ... | 10
1          | BlackCat  | ... | 11   ← BlackCat's whole row copied again
1          | BlackCat  | ... | 12   ← and again (×50)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;200 couriers × 50 each → 10,000 rows, parent columns copied over and over. Transfer size and memory both balloon, and Rails still has to dedupe it back into objects. At scale, it drags.&lt;/p&gt;




&lt;h3&gt;
  
  
  ✅ The fix: joins to filter + distinct to dedupe + preload to load
&lt;/h3&gt;

&lt;p&gt;The principle: &lt;strong&gt;"filtering" and "displaying" are two separate jobs — split them.&lt;/strong&gt; Filter with &lt;code&gt;joins&lt;/code&gt; (doesn't pull child columns, avoids the explosion), display with &lt;code&gt;preload&lt;/code&gt; (a separate &lt;code&gt;IN&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;Two tools first:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;joins&lt;/code&gt;&lt;/strong&gt;: does an INNER JOIN but &lt;strong&gt;only &lt;code&gt;SELECT&lt;/code&gt;s the parent's columns&lt;/strong&gt;. The child is just a filter, never loaded into objects.&lt;br&gt;
(Note: "doesn't pull the child back" isn't a property of JOIN — it's a consequence of &lt;code&gt;joins&lt;/code&gt; only selecting the parent. JOIN connects tables; SELECT decides which columns come back. &lt;code&gt;joins&lt;/code&gt; writes &lt;code&gt;SELECT couriers.*&lt;/code&gt;; &lt;code&gt;eager_load&lt;/code&gt; writes &lt;code&gt;SELECT couriers.*, shipments.*&lt;/code&gt;. That's the whole difference.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;distinct&lt;/code&gt;&lt;/strong&gt;: folds identical rows into one. Because &lt;code&gt;joins&lt;/code&gt; on a has_many duplicates the parent, but the child columns aren't selected, those rows are "identical in every column" — so &lt;code&gt;distinct&lt;/code&gt; collapses them into one.&lt;/p&gt;

&lt;p&gt;Put together:&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="n"&gt;couriers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;Courier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;joins&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:shipments&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                          &lt;span class="c1"&gt;# INNER JOIN, only to filter&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="ss"&gt;shipments: &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="ss"&gt;status: &lt;/span&gt;&lt;span class="s2"&gt;"delivered"&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;  &lt;span class="c1"&gt;# filter couriers by a child column&lt;/span&gt;
                  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;distinct&lt;/span&gt;                                    &lt;span class="c1"&gt;# drop duplicate couriers from the JOIN&lt;/span&gt;
                  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;preload&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="ss"&gt;:shipments&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                         &lt;span class="c1"&gt;# separately load shipments to display&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The actual SQL — two queries, each with one job:&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="c1"&gt;-- ① joins + distinct: only to find couriers WITH a delivered shipment; child columns not selected → no explosion&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="n"&gt;couriers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;couriers&lt;/span&gt;
&lt;span class="k"&gt;INNER&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;shipments&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;shipments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;courier_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;couriers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;shipments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'delivered'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- say this returns couriers 1 and 2&lt;/span&gt;

&lt;span class="c1"&gt;-- ② preload: a separate IN to load the shipments to display, into ④ memory&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;shipments&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;courier_id&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The view uses &lt;code&gt;c.shipments&lt;/code&gt; (already preloaded):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight erb"&gt;&lt;code&gt;&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="n"&gt;couriers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;each&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
  &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;name&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;: &lt;span class="cp"&gt;&amp;lt;%=&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;shipments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;size&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
&lt;span class="cp"&gt;&amp;lt;%&lt;/span&gt; &lt;span class="k"&gt;end&lt;/span&gt; &lt;span class="cp"&gt;%&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Filtering goes to the JOIN, loading goes to preload&lt;/strong&gt; — so you get both "filter the parent by a child column" and "no row explosion."&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Note that &lt;code&gt;preload(:shipments)&lt;/code&gt; here loads &lt;em&gt;all&lt;/em&gt; shipments (including non-delivered ones). If you want "keep only couriers with a delivered shipment &lt;strong&gt;and&lt;/strong&gt; display only the delivered ones," combine A and B: &lt;code&gt;joins(:delivered_shipments).distinct.preload(:delivered_shipments)&lt;/code&gt;. Scoped associations and joins compose.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  🧭 Wrap-up: every rule is a corollary of the same foundation
&lt;/h2&gt;

&lt;p&gt;Looking back, this post started from one "added includes but still N+1" incident and ended up at a single thing: &lt;strong&gt;for the line of Ruby you wrote, what happens in which layer.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Once the four layers click, all the scattered "rules" connect:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;preload can't filter by an association column — because filtering is in ③, and the child table isn't in the main SQL.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;c.shipments.where(...)&lt;/code&gt; breaks the preload — because &lt;code&gt;.where&lt;/code&gt; re-queries ③, ignoring the ④ in-memory set.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;joins&lt;/code&gt; doesn't load the association, &lt;code&gt;eager_load&lt;/code&gt; does — the difference is which columns SELECT pulls.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.size&lt;/code&gt; doesn't hit the DB, &lt;code&gt;.count&lt;/code&gt; does — &lt;code&gt;.size&lt;/code&gt; counts the ④ array, &lt;code&gt;.count&lt;/code&gt; goes back to ③ with a SQL statement.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And "Need A vs Need B" is just two sides of the same question: &lt;strong&gt;are you filtering the child or the parent?&lt;/strong&gt; Answer that and the solution falls out — filter the child with a scoped association, filter the parent with joins (and preload if row explosion is a concern).&lt;/p&gt;

&lt;h3&gt;
  
  
  🗺️ Appendix: the full N+1 map
&lt;/h3&gt;

&lt;p&gt;"Filter child/parent" is one piece — don't mistake it for the whole. The full breakdown:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;First cut: is it preloaded at all?
  No  → add includes (fixes ~80%, the basics)
  Yes but still slow ↓
Second cut: HOW are you using the preloaded data?
  ├ Filtering with a condition → filter child (scoped association) / filter parent (joins + preload)  ← this post
  ├ .count in a loop → use .size (counts memory) or counter_cache (skips preload entirely)
  └ Nested associations: c.shipments.each { s.packages } → includes(shipments: :packages)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The first cut is the basics — an &lt;code&gt;includes&lt;/code&gt; away. The one that trips people up is the second cut: "I &lt;em&gt;did&lt;/em&gt; preload, why is it still slow?" That's what this post is about.&lt;/p&gt;

&lt;p&gt;Optimization isn't memorizing a checklist — it's being able to ask the right question about a slow query: is this step in the DB or in memory? Is the column in that SQL statement? Am I filtering the child or the parent? Answer those and the fix usually follows. This lens carries over to any language's ORM (Django ORM, Eloquent, Hibernate), because they all do the same thing — turn objects into SQL, across databases.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>activerecord</category>
      <category>performance</category>
      <category>sql</category>
    </item>
    <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;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Rails Performance: Lessons from Production — overview&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Before optimizing individual layers — N+1, indexes, caching — you need a way to find &lt;em&gt;which&lt;/em&gt; layer is actually slow. This is the layer-by-layer diagnostic model the rest of the series builds on, walked end to end on one real slow page.&lt;/p&gt;
&lt;/blockquote&gt;




&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>
