<nav class="site-nav" data-controller="preload">
<ul>
<li>
<%= link_to "Dashboard", dashboard_path,
class: "nav-link",
data: { turbo_preload: true } %>
</li>
<% @featured_articles.each do |article| %>
<li>
<%= link_to article.title, article_path(article),
class: "nav-link",
data: {
turbo_preload: true,
preload_target: "link"
} %>
</li>
<% end %>
<li>
<%# not preloaded: low intent + has query params %>
<%= link_to "Search", search_path, class: "nav-link" %>
</li>
</ul>
</nav>
class ArticlesController < ApplicationController
before_action :set_article, only: :show
def show
# Cacheable + revalidatable so Turbo preloads are cheap and safe.
expires_in 5.minutes, public: false
fresh_when(etag: @article, last_modified: @article.updated_at)
# Side effects must NOT run during a speculative preload request.
unless turbo_preload_request?
RecordViewJob.perform_later(@article.id, current_user&.id)
end
end
private
def set_article
@article = Article.published.find(params[:id])
end
def turbo_preload_request?
# Turbo sends this header on prefetch/preload requests.
request.headers["X-Sec-Purpose"].to_s.include?("prefetch") ||
request.headers["Purpose"].to_s == "prefetch"
end
end
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["link"]
connect() {
this.observer = new IntersectionObserver(
(entries) => this.onIntersect(entries),
{ rootMargin: "200px" }
)
this.linkTargets.forEach((link) => this.observer.observe(link))
}
disconnect() {
if (this.observer) this.observer.disconnect()
}
onIntersect(entries) {
entries.forEach((entry) => {
if (!entry.isIntersecting) return
const link = entry.target
this.preload(link.href)
this.observer.unobserve(link)
})
}
preload(url) {
if (this.preloaded && this.preloaded.has(url)) return
this.preloaded = this.preloaded || new Set()
this.preloaded.add(url)
fetch(url, {
headers: {
"Accept": "text/html",
"X-Sec-Purpose": "prefetch",
"Turbo-Frame": ""
},
credentials: "same-origin"
}).catch(() => this.preloaded.delete(url))
}
}
This snippet shows how Turbo Drive's link preloading is wired up in a Rails app to make navigation feel instant. The core idea is that Turbo can fetch a page in the background before a click happens — either on hover (data-turbo-preload combined with the built-in preloading behavior) or eagerly when the link scrolls into view — so that by the time a user actually clicks, the HTML is already sitting in Turbo's cache and rendering is near-instantaneous. It trades a little extra network traffic for a large improvement in perceived performance, which is often what matters for a snappy UI.
In _nav.html.erb, the important links carry data: { turbo_preload: true }. Turbo's LinkPrefetchObserver picks these up and issues a background fetch for the target href, storing the response so the subsequent visit is served from cache rather than a fresh round-trip. Marking only high-intent links (dashboard, top articles) avoids preloading everything, which would waste bandwidth and hammer the server.
Because preloading only helps when the response is cacheable, ArticlesController sets an explicit Cache-Control header via expires_in and relies on fresh_when with an ETag so repeat fetches can be revalidated cheaply. The show action is deliberately idempotent and side-effect free; anything with side effects (like recording a view) must not run during a speculative preload, which is why that work is pushed to a RecordViewJob triggered separately.
The preload_controller.js Stimulus controller handles the eager case that Turbo does not do out of the box: it uses an IntersectionObserver to detect when a flagged link enters the viewport and asks Turbo to preload it via Turbo.session.preloadOnHover-style prefetching. Disconnecting the observer in disconnect() prevents leaks across Turbo navigations.
The trade-offs are real: speculative fetches can trigger analytics or write paths if a controller is careless, cached HTML can go stale, and mobile users may not want the extra data. Guarding side effects, setting sane cache lifetimes, and preloading selectively keep those risks in check while delivering the instant-feel navigation Turbo makes possible.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
System test: asserting Turbo Stream responses
class Post < ApplicationRecord
belongs_to :author, class_name: 'User'
has_many :comments, dependent: :destroy
scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
scope :draft, -> { where(published_at: nil) }
ActiveRecord scopes for reusable query logic
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
class PostsController < ApplicationController
def index
@posts = Post.includes(:author)
.order(created_at: :desc)
.page(params[:page])
.per(10)
Turbo Frames: infinite scroll with lazy-loading frame
require "csv"
class PeopleCsvStream
include Enumerable
HEADERS = %w[id full_name email signed_up_at plan].freeze
Resilient CSV Export as a Streamed Response
<form data-controller="query-sync" data-action="change->query-sync#apply">
<select name="status" class="rounded border p-2">
<option value="">Any</option>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
Filter UI that syncs query params via Stimulus (no front-end router)
Share this code
Here's the card — post it anywhere.