Pitfalls & Burns

Turbo Drive

Prefetch Surprise

Turbo prefetches every link on hover by default, so slow pages and view counters get hit for pages nobody opens. Invert the default and opt in where intent is high.

app/models/product.rb
1
2
3
4
5
6
7
8
class Product < ApplicationRecord
  has_one_attached :image

  validates :name, :price, presence: true

  scope :featured, -> { where(featured: true) }
  scope :browsable, -> { where(featured: false) }
end
The shop before the fix. Sweeping the grid requests every product page and bumps its view count.
The shop after the fix. Only the featured row prefetches, and prefetches aren't counted.

The scenario

We run a busy shop with a product grid. Product pages are slow to render, and each view increments a counter we use to decide what's popular. The pitfall code is nothing special: a grid of link_to cards in _product.html.erb and a show action that calls increment!(:views_count).

The burn

Move the cursor across the grid without clicking, then reload. The view counts have gone up, and the server has rendered a page for every card the cursor lingered on.

Since Turbo 8, Drive prefetches any link the user hovers over for 100 milliseconds. Nobody opted in; it's on for every link by default. When the hover turns into a click, the page appears instantly. But most hovers never become clicks, and each one costs a full server render that gets thrown away. On an app that hasn't already made its pages cheap, that adds up fast.

The second surprise is side effects. Anything a GET request does when a page is "viewed", whether counting it, setting a cookie, or marking something as seen, now also happens for pages nobody looked at. Here that corrupts the popularity numbers, and anything built on them.

The fix

Invert the default. Put data-turbo-prefetch="false" on <body> in the layout, as the comment at the top of the refactored index.html.erb notes, and nothing prefetches. A link's own data-turbo-prefetch="true" overrides an ancestor's false, so we opt back in where intent is high: _product.html.erb takes a prefetch local, and index.html.erb passes true for the "Popular right now" row and false for the rest of the grid.

For the side effect, Turbo marks its prefetch requests with an X-Sec-Purpose: prefetch header. The refactored products_controller.rb checks for it and skips the counter.

Why it matters

Prefetch is a bet, and caching sets the odds. A wasted prefetch of a cached page costs almost nothing; a wasted prefetch of an expensive, uncached render costs a lot. The same feature can be a good deal on one surface and a bad one on another, so decide per surface based on what a wasted request costs you. That's much easier when prefetching is something you turn on, not something you discover.

There's an honest gap in the header check: a click served from the prefetch cache never makes a second request, so a prefetched-then-clicked page isn't counted at all. The complete fix is to record the view from the browser after the page is actually shown.

Read More