Ruby on Rails and Hotwire
The first thing to know about Hotwire in a Rails application is that you do not install it. It is
already running, it has been since rails new, and the only thing it asks of you is one HTTP rule
that Rails did not enforce before. Almost everything written about "adding Hotwire to Rails" is
describing a 2021 problem that no longer exists.
Everything below was run today in a scratch application generated with
rails new hw -d postgresql --skip-kamal --skip-solid on Ruby 4.0.5, against PostgreSQL 17.7 on
port 15432, on an Apple M2 Max under macOS 26.5.1. The browser is Chrome 153.0.8010.53 driven by
Capybara and Selenium from bin/rails test:system. The lockfile that produced every number here
resolved rails (8.1.4), turbo-rails (2.0.23), stimulus-rails (1.3.4), importmap-rails
(2.2.3) and propshaft (1.3.2).
There is no gem called hotwire
Hotwire is a name for two separate gems and a delivery mechanism, and none of the three is called
hotwire. These are the only lines rails new writes about it, copied out of the generated Gemfile:
# Use JavaScript with ESM import maps [https://github.com/rails/importmap-rails]
gem "importmap-rails"
# Hotwire's SPA-like page accelerator [https://turbo.hotwired.dev]
gem "turbo-rails"
# Hotwire's modest JavaScript framework [https://stimulus.hotwired.dev]
gem "stimulus-rails"
A hotwire-rails gem does exist on rubygems, and people still find it and add it. I fetched
version 0.1.3 and unpacked it. It was released on 2021-01-15, it contains three Ruby files totalling
15 lines, and this is all of lib/hotwire-rails.rb:
module Hotwire
end
require "hotwire/version"
require "hotwire/engine"
require "turbo-rails"
require "stimulus-rails"
Its lib/tasks/hotwire_tasks.rake is four lines and delegates to the two installers that already
exist:
namespace :hotwire do
desc "Install Hotwire into the app"
task install: [ "stimulus:install", "turbo:install" ] do
end
end
That is the entire gem. It was a convenience for Rails 6 applications in the month Turbo was
announced, and adding it to a Rails 8 app buys an empty Rails::Engine and a rake task you will not
run. The reason bin/rails hotwire:install fails in a modern app is not that something is missing:
Unrecognized command "hotwire:install" (Rails::Command::UnrecognizedCommandError)
bin/rails -T in the generated app lists importmap:install plus turbo:install and
stimulus:install, the last two each with :bun, :node and :importmap variants. All three
already ran.
What rails new actually put in the page
A default Rails 8.1 application ships two files of JavaScript that you own. app/javascript/application.js
is two lines:
// Configure your import map in config/importmap.rb. Read more: https://github.com/rails/importmap-rails
import "@hotwired/turbo-rails"
import "controllers"
and config/importmap.rb is five pins:
pin "application"
pin "@hotwired/turbo-rails", to: "turbo.min.js"
pin "@hotwired/stimulus", to: "stimulus.min.js"
pin "@hotwired/stimulus-loading", to: "stimulus-loading.js"
pin_all_from "app/javascript/controllers", under: "controllers"
<%= javascript_importmap_tags %> in the layout turns those pins into an inline import map and a
single <script type="module">import "application"</script>. Nothing is bundled and nothing is
compiled. Fetched one at a time from the development server, the seven modules the browser ends up
with weigh 155,355 bytes: turbo.min.js 105,579, stimulus.min.js 45,657, stimulus-loading.js
3,315, and 804 bytes across application.js, controllers/index.js, controllers/application.js
and the generated hello_controller.js. That figure agrees with the one measured independently for
Rails vs JavaScript, which is mildly reassuring and mostly means the
default has not moved.
rails new --skip-hotwire removes turbo-rails and stimulus-rails from the Gemfile and leaves
importmap-rails and propshaft in place. It is a real flag and it does what it says.
Turbo Drive is the rung you are already standing on
Turbo Drive intercepts every same-origin link click and form submission and replaces the <body>
instead of letting the browser load a new document. No attribute turns it on and no code opts into
it. The observable consequence is that the JavaScript heap survives navigation, which is the whole
mechanism and also the easiest thing to test:
test "Turbo Drive swaps the body and keeps the same document" do
visit "/drive/a"
assert_selector "#page", text: "A"
page.execute_script("window.__stamp = 'kept'")
click_on "Go to B"
assert_selector "#page", text: "B"
assert_equal "kept", page.evaluate_script("window.__stamp")
assert_equal "/drive/b", URI.parse(current_url).path
end
The stamp survives, so the document was never torn down: the 155,355 bytes of JavaScript were parsed once and the second page reuses them. The address bar still moved, because Drive pushes history. What Turbo Drive sends and what the server renders for it is server side rendering in the ordinary sense, and none of it needs a template evaluated in the browser.
The one rule Rails did not have before Turbo
A non-GET form submission that answers 200 without redirecting is discarded by Turbo, silently as
far as your server is concerned. This is the single behaviour change that catches Rails developers
moving an older application forward, and it is one line in turbo.js:
if (this.requestMustRedirect(request) && responseSucceededWithoutRedirect(response)) {
const error = new Error("Form responses must redirect to another location");
this.delegate.formSubmissionErrored(this, error);
}
with the two predicates it depends on being exactly as blunt as they look:
requestMustRedirect(request) {
return !request.isSafe && this.mustRedirect;
}
function responseSucceededWithoutRedirect(response) {
return response.statusCode == 200 && !response.redirected;
}
I wrote the controller a Rails developer writes without thinking about it, differing from the
scaffold only in the missing status::
class LegacyPostsController < ApplicationController
def create
@post = Post.new(params.expect(post: [ :title, :body ]))
if @post.save
redirect_to posts_path
else
render :new
end
end
end
Submitting that form with an empty title in Chrome leaves the page exactly as it was. No error
list, no flash, no visible failure. This is the entire browser log afterwards, pasted from
page.driver.browser.logs.get(:browser):
http://127.0.0.1:64290/assets/turbo.min-9fd88cd5.js 18:21080 Error: Form responses must redirect to another location
at ie.requestSucceededWithResponse (http://127.0.0.1:64290/assets/turbo.min-9fd88cd5.js:5:15107)
at z.receive (http://127.0.0.1:64290/assets/turbo.min-9fd88cd5.js:5:9461)
at z.perform (http://127.0.0.1:64290/assets/turbo.min-9fd88cd5.js:5:9082)
The fix is the keyword the Rails 8.1 scaffold generator already writes for you:
format.html { render :new, status: :unprocessable_content }. Note the symbol. Rack renamed 422 in
3.x and Rack::Utils::HTTP_STATUS_CODES[422] now reads "Unprocessable Content", with
SYMBOL_TO_STATUS_CODE holding {unprocessable_content: 422} and nothing else for that code. Old
controllers are not broken by this: on rack 3.2.7,
Rack::Utils.status_code(:unprocessable_entity) still returns 422. I checked before writing the
sentence, because the obvious guess is that it raises. It does not raise, it complains:
warning: Status code :unprocessable_entity is deprecated and will be removed in a future version of Rack. Please use :unprocessable_content instead.
That warning is the only notice you get, and in an application with a busy log it is one line among thousands.
Two things make this expensive to find rather than merely annoying. requestMustRedirect is false
for safe methods, so a GET search form rendering 200 is fine and only writes are affected, which
means half your forms work. And the server has no idea: assert_response :success passes, the
development log prints Completed 200 OK, and the failure lives entirely in a console nobody has
open.
The same 200 is accepted inside a turbo-frame
A form wrapped in <turbo-frame> is allowed to answer 200 with no redirect, with the same
controller code that fails outside one. The asymmetry is not documented as a rule anywhere I could
find; it falls out of which object builds the FormSubmission. The constructor signature at
turbo.js:920 is constructor(delegate, formElement, submitter, mustRedirect = false).
Navigator, the Drive path, passes true at line 3470:
this.formSubmission = new FormSubmission(this, form, submitter, true);
FrameController does not, at line 4774:
this.formSubmission = new FormSubmission(this, element, submitter);
so mustRedirect stays false and the check never fires. I put the same broken create behind two
templates, one bare and one wrapped in turbo_frame_tag "post_form", and asserted both directions:
test "a 200 response to a form submission is discarded by Turbo" do
visit "/legacy_posts/new"
watch_submit_end
click_on "Create"
await_submit_end
assert_no_selector "#errors"
assert_match "Form responses must redirect to another location", browser_log
end
test "the same 200 response is accepted when the form is inside a Turbo Frame" do
visit "/framed_posts/new"
watch_submit_end
click_on "Create"
await_submit_end
assert_selector "#errors", text: "1 error prohibited this post from being saved"
refute_match "Form responses must redirect", browser_log
end
Both pass. That is worth knowing in both directions: it explains why a form you moved into a frame
suddenly started showing its validation errors, and it means a frame is not a general excuse to skip
the status code, because the day somebody adds target="_top" the submission goes back through
Navigator and the 200 starts being thrown away again. Write the status code anyway. A frame
response that comes back without a matching frame id fails in a different and equally quiet way,
which is Turbo frame: Content missing.
What each rung costs, over 200 rows
Drive, Frames and Streams are three sizes of the same answer, and the only honest way to pick is to
measure what the server has to say. I built a list of 200 <li> rows and asked for the same update
three ways from one ActionDispatch::IntegrationTest, in the test environment with no view
annotations:
get ladder_path, headers: { "Accept" => "text/html, application/xhtml+xml" }
full = response.body.bytesize
get ladder_path, headers: { "Accept" => "text/html, application/xhtml+xml", "Turbo-Frame" => "list" }
frame = response.body.bytesize
post ladder_add_path, headers: { "Accept" => "text/vnd.turbo-stream.html, text/html, application/xhtml+xml" }
stream = response.body.bytesize
full page = 7912 bytes
frame only = 6248 bytes
stream = 111 bytes
The frame saved 1664 bytes, which is the layout and the <h1>, and rendered all 200 rows anyway: a
Turbo-Frame header narrows what the browser swaps, not what the server renders. The stream is 111
bytes because it is the one row that changed:
<turbo-stream action="append" target="list"><template><li id="post_201">row 201</li>
</template></turbo-stream>
That is a 71x difference, and it is the only argument for Turbo Streams that holds up. Reach for a stream when the response would otherwise be mostly bytes the page already has, or when the update has to arrive without a request, which is the broadcast case. Do not reach for one because it feels more modern: you are trading a rendered page for eight actions whose target ids nothing verifies, and a stream aimed at an id that is not in the document does nothing at all, with no error.
Two measurements that were wrong before they were right
The first set of page sizes I measured was 3.2x too large, and the cause was not Hotwire. Fetching
/ladder from bin/rails s returned 26,029 bytes where the test environment returned 7,912. The
difference is config.action_view.annotate_rendered_view_with_filenames = true, on line 68 of the
generated config/environments/development.rb, which wraps every partial render in a pair of HTML
comments:
<!-- BEGIN app/views/layouts/application.html.erb
--><!DOCTYPE html>
There were 202 of those BEGIN comments in the response, one per row plus the layout and the index.
Flipping the flag to false and restarting brought the same page to 8,235 bytes. Any byte
comparison you run against a development server is measuring that flag as much as your markup.
The second wrong measurement was the browser log, which came back empty. click_on returns as soon
as the click is dispatched, and a Turbo form submission is a fetch, so the assertion ran before
Turbo had a response to complain about. Chrome's log is fine, the test was early. The fix is to wait
for the event Turbo fires when the submission settles:
def watch_submit_end
page.execute_script(<<~JS)
window.__submitEnd = false
addEventListener("turbo:submit-end", () => { window.__submitEnd = true }, { once: true })
JS
end
logs.get(:browser) also drains the buffer, so a helper that reads it twice in one test gets the
lines once.
Where Stimulus comes in, briefly
Stimulus is the part of Hotwire that has nothing to do with HTTP, and an application can go a long
way without writing a controller of its own. Reach for it when the behaviour cannot be a round trip
at all: focus, drag, a keyboard shortcut, a countdown. The generated hello_controller.js connects
on its own with no registration step, which the test confirms in one line, and the rest of the
subject, including why connect() runs twice on a back button, is in
Stimulus controllers in practice.
The position, and what would change it
Hotwire is the correct default for a Rails application and the ladder should be climbed one rung at a time, from the bottom, with a measurement as the reason each time. Most applications never need rung two. The version of this advice worth arguing with is the cost, so here it is.
You pay 155,355 bytes of JavaScript on first load for behaviour a plain <a> already had, and a
page with no interactivity at all pays it too. You pay a second, larger cost in where failures live.
The request test for the broken controller above passes, and I ran it:
1 runs, 6 assertions, 0 failures, 0 errors, 0 skips
Green, on a controller whose form does nothing in a browser. Once navigation is a fetch, a green
server test stops being evidence that the feature works, and the only test that knows the truth is a
system test that costs a real Chrome. The seven browser tests behind this post finished in 2.16,
2.58, 2.96 and 3.35 seconds across four consecutive runs on the machine described at the top, which
is 310 to 480 ms an example and not a stable enough number to quote as one. A suite with a hundred
of them is a different kind of afternoon.
What would change the position: a Rails-side check that fails when a non-GET HTML action can return
200 without redirecting. Nothing in the framework does that today, bin/rails test is green either
way, and until that exists the rule is enforced by whoever remembers it.
What this post does not cover
Turbo Streams over a WebSocket, which is turbo_stream_from, broadcast_append_to and Solid Cable,
and is a different post with a different failure mode: a subscription that never attaches looks
exactly like a broadcast that never fired. Morphing and data-turbo-permanent, which is how
Turbo 8 refreshes a page without losing scroll and focus. Native, which is the fourth thing in the
Hotwire box and needs an iOS or Android project to say anything true about. Prefetch on hover,
data-turbo-track, and the snapshot cache, all of which change what the reader sees before the
server is involved. The comparison against a client-rendered stack, with both applications built and
both measured, is in Rails vs React.
No third-party benchmark appears above. Every number came from the scratch application described at the top, and the full test run behind it is 7 system examples with 26 assertions and 2 other examples with 13, all passing.
Comments
No comments yet. Be the first.