Pitfalls & Burns

Turbo Frames

Lazy N+1s

A frame on every row turns one page into a request (and a few queries) per item, defers nothing, and leaves nowhere to put includes. One combined render batches it away.

app/views/pipeline_runs/_summary.html.erb
1
2
3
4
5
6
7
8
9
10
11
12
<%# Run-level header — cheap, all from the runs table. %>
<% badge = { "passed" => "bg-green-100 text-green-800",
             "failed" => "bg-red-100 text-red-800",
             "running" => "bg-blue-100 text-blue-800" }.fetch(run.status, "bg-gray-100 text-gray-700") %>
<div class="flex items-baseline justify-between gap-3">
  <p class="truncate">
    <span class="font-mono text-sm font-medium">#<%= run.number %></span>
    <span class="ml-1 text-sm text-gray-700"><%= run.commit_message %></span>
  </p>
  <span class="shrink-0 rounded px-2 py-0.5 text-xs font-medium <%= badge %>"><%= run.status %></span>
</div>
<p class="mt-1 text-sm text-gray-500"><%= run.branch %> · <%= run.committer %></p>
Pipeline runs, pitfall version. The request panel counts 21 requests and 81 queries for one page.
Pipeline runs, refactored version. The same page in 1 request and 3 queries.

The scenario

We work on a continuous integration product, and we're building a page that lists the latest pipeline runs. Each run has a summary line, plus a detail block listing its stages with their results and the artifacts it produced. Stages and artifacts are both associations on PipelineRun, so the detail is the heavy part of the page.

The burn

One of the nicest things about Turbo Frames is that a frame can load itself. Give turbo_frame_tag a src and the browser fetches that URL and fills in the frame. So in index.html.erb we render each run's summary and then a frame per row pointing at the run's show action, which renders _detail.html.erb inside a matching frame. Each row is now a component we can re-render on its own later.

It's easy to forget that every one of those frames is a separate request. Twenty runs on the page means one request for the index and twenty more for the details, about 81 SQL queries in all. And nothing is being saved for later: every frame fetches as soon as the page loads, so we get exactly the page one request could have rendered.

Worse, the usual cure for an N+1 is gone. includes needs a collection to attach to, but the show action in pipeline_runs_controller.rb loads a single run with find. There's no place where the runs are loaded together, so there's nowhere to batch their stages and artifacts.

The fix

We drop the per-row frames and render the collection we already have. The index action loads the runs with includes(:stages, :artifacts), and index.html.erb renders _detail.html.erb inline for each run. The show action and its template go away.

That's one request and three queries: one for the runs, one for all their stages, one for all their artifacts. It stays three queries however many runs the page shows.

Why it matters

In our local run, the frames version did more than three times the total server work for an identical page. Every one of its requests looks fast on its own, so nothing in per-request tooling or an APM looks wrong. The waste only appears in aggregate, and that's where capacity lives: each page view takes twenty-one request slots instead of one, pays the fixed per-request cost of middleware, sessions, and layout twenty-one times, and sends many times the queries to the database, the hardest tier to scale.

What about making the frames lazy, so they only load near the viewport? That's a real judgement call. It can come out ahead when each item is heavy and usually unseen, like a log viewer where people open one run in a hundred. On a list people actually read, we pay for the extra requests to defer work that happens anyway. The tell is the hit rate. And even when lazy loading wins, there's still nowhere to put includes.

Read More