A no-nonsense plain English Turbo Streams explainer
I asked o3 to teach me about Turbo Streams without using too many technical words and using English. Here's that explainer.
Let’s start from the big picture — why does Turbo Streams exist?
Back when Rails first shipped (2004‑ish), every “live” interaction meant sprinkling hand‑rolled JavaScript or, later, adopting a front‑end SPA framework. The result was two apps in one code‑base: Rails on the server, React/Vue/whatever on the client. Hotwire (2020) was DHH’s declaration that HTML‑over‑the‑wire can still win for most business UIs. Turbo Streams is the piece that answers: “How do I make a page update itself in real‑time without writing a socket handler in JS?”
1. The mental model in plain English
Server decides what the new HTML should be. Think of it like a pizza shop: the kitchen (Rails) cooks the slice; the delivery guy (Turbo) only drops it off.
Turbo wraps that slice in a tiny envelope called
<turbo-stream>
containing:action – what to do (
append
,replace
,remove
… there are nine verbs).target – where in the DOM to do it (an element’s
id
).
Browser receives the envelope (via a normal form POST or a WebSocket broadcast), opens it, and performs the tiny DOM surgery—no page reload, no client JS from you.
In short: HTML diffs sent over the wire.
2. Why nine verbs, not a full DOM diff?
Action Plain‑English meaning append
/ prepend
Add something inside a list before
/ after
Drop something next to an element replace
Swap one element entirely update
Swap only the inside of an element remove
Delete it morph
(Turbo 8) Smart diff/patch to avoid full replace refresh
Reload the whole page (escape hatch)
Nine verbs cover 99 % of routine UI changes while keeping mental overhead low. When you need custom behavior (e.g., flash‑and‑fade highlight) you can add your own action with a few lines of JS instead of a React component.
3. Life without JavaScript: a story in two requests
A. User‑initiated update (HTTP round‑trip)
User hits “Create comment”.
Rails
CommentsController#create
saves the record and renders create.turbo_stream.erb:
<%= turbo_stream.append "comments",
partial: "comments/comment",
locals: { comment: @comment } %>
Browser swaps the new
<li>
in. The whole payload is maybe 400 B.
B. Background update (WebSocket broadcast)
A
Message
model hasbroadcasts_to :room
.Any browser that called
<%= turbo_stream_from @room %>
is subscribed.When a new message saves, Rails broadcasts an
append
stream; all clients receive it in milliseconds.
4. When should you reach for Turbo Streams?
Use‑case Why Turbo Streams fits Comment threads / task lists Users expect the item to appear instantly after they press Save. Live chat & notifications Same payload hits everyone subscribed; no extra JS. Status dashboards Background job updates DB → model broadcast updates UI. CSV import progress bars Job broadcasts percentage to a progress_bar
target.
If you only need local interactivity (open/close accordion), a Stimulus controller is lighter. If the user navigates between pages inside the same container, Turbo Frames is the better tool.
5. Under the hood (but still English!)
turbo-rails
gem wires Action Cable, rendering, and helper methods together.Server responds with
Content-Type: text/vnd.turbo-stream.html
.Browser’s tiny Turbo JS (~15 KB) parses the stream and calls
StreamActions[action]
.Turbo 8 introduced a smarter diff engine (“morph‑dom”) so CSS transitions and input focus survive more often.
6. Common questions, demystified
7. A five‑minute “hello world” you can try now
rails new streams_demo --database=postgresql
cd streams_demo
rails g scaffold Post title body:text
rails db:migrate
Open app/views/posts/create.turbo_stream.erb
(Rails generated it):
<%= turbo_stream.prepend "posts",
partial: "posts/post",
locals: { post: @post } %>
Start the app, submit the form, watch the row appear without reload. You just shipped real‑time UI with zero custom JS.
8. Where do *.turbo_stream.erb
views fit in the MVC flow?
TL;DR They are siblings to your regular
*.html.erb
templates, selected when the request’s format isturbo_stream
instead ofhtml
.
1. File‑naming convention
Purpose Expected file name Plain HTML response create.html.erb
(or .haml
, etc.) Turbo Stream response create.turbo_stream.erb
Rails’ view resolver looks for a template whose extension stack matches the requested MIME type. Turbo Rails registers text/vnd.turbo-stream.html
as the turbo_stream
format.
2. Controller “traffic‑cop”
def CommentsController#create
@comment = Comment.create!(comment_params)
respond_to do |format|
format.turbo_stream # → renders create.turbo_stream.erb
format.html { redirect_to @comment.post } # → renders/create.html.erb or redirects
end
end
At runtime Turbo Drive submits the form using fetch
, setting Accept: text/vnd.turbo-stream.html, text/html
in the header. Rails picks the first format it can satisfy—so it renders the *.turbo_stream.erb
template and sends back the stream.
If a crawler (or someone with JS disabled) submits the same form, the browser does a full POST → Rails falls through to the html
branch and issues a redirect, preserving the classic UX.
3. Do turbo‑stream views get a layout?
No. They render only the envelope(s) you specify. That keeps the payload tiny:
<!-- create.turbo_stream.erb -->
<%= turbo_stream.append "comments",
partial: "comments/comment",
locals: { comment: @comment } %>
4. How they work with partials
turbo_stream.*
views almost always delegate real HTML to a familiar partial—_comment.html.erb
, _post_row.html.erb
, etc.—so you avoid duplication between HTML and stream contexts.
5. Debug tip
Add ?format=turbo_stream
to any path in development to see the raw <turbo-stream>
output, or use Curl -H "Accept: text/vnd.turbo-stream.html"
to hit the endpoint.
6. Mental picture
Browser form submit
↓
Turbo Drive sets Accept header → Controller → respond_to
↓ ↓
text/vnd.turbo-stream.html text/html
↓ ↓
create.turbo_stream.erb create.html.erb (or redirect)
↓ ↓
<turbo-stream> envelope Full page HTML
So, think of *.turbo_stream.erb
as tiny, layout‑less view templates Rails calls to instruct the browser how to mutate the DOM, while your familiar *.html.erb
files still serve the full page experience.
10. Turbo Streams vs AJAX + Stimulus – should you switch?
QuestionTurbo StreamsAJAX (XHR/fetch
) + StimulusPrimary jobDeliver server‑rendered HTML fragments and mutate the DOM for youDeliver raw data (JSON, HTML, etc.) – you write JS to update the DOMBoilerplateZero client JS for CRUD ops; server helpers do the heavy liftingYou hand‑code fetch
, this.element.innerHTML = …
(or similar)Real‑time broadcastingBuilt‑in via turbo_stream_from
/ broadcasts_to
Needs Action Cable channels + custom JS to consume messagesGranular UI controlLimited to 9 verbs (plus custom actions)Total freedom – can animate, debounce, throttle however you likeDebuggingCheck the rendered <turbo-stream>
outputConsole‑log JSON/HTML and JS stack tracesTypical sweet spotCRUD tables, comments, notifications, dashboardsAutocomplete, drag‑and‑drop, pixel‑perfect animations
TL;DR decision tree
Is the update mostly CRUD‑ish? (create/delete/update row, flash counter, etc.)
→ Use Turbo Streams – less code, server keeps single source of truth.Do you need rich client behavior? (sorting without server hit, complex animation, talking to 3rd‑party APIs from browser)
→ Stick with Stimulus + AJAX.Real‑time multi‑user view of the same data?
→ Turbo Streams shines; broadcasting is basically one line in the model.Both?
→ Mix and match! Stimulus controllers can happily live inside HTML that arrived via Turbo Streams.
Example mix
<!-- _task.html.erb (rendered via Turbo Stream) -->
<li id="task_<%= task.id %>" data-controller="sortable" data-action="dragend->sortable#reorder">
<span><%= task.title %></span>
</li>
The list item arrived over the wire thanks to Turbo, but once in the DOM the sortable
Stimulus controller takes over client‑side drag‑and‑drop.
Migration strategy
Convert one small AJAX‑based feature to Turbo Streams (e.g., comment creation).
Delete the Stimulus
fetch
handler and compare lines of code & test coverage.Keep Stimulus for interactions that are purely client‑side or too bespoke for the nine verbs.
Rule of thumb: If the update must touch the database anyway, let the server render the HTML and ship it via Turbo. If the update is cosmetic or data comes from a JS‑only source (WebRTC, localStorage, drag‑preview), stay in Stimulus land.
In one sentence
Turbo Streams lets Rails ship tiny, server‑rendered HTML patches—over form submits or WebSockets—so your pages feel alive without the SPA tax.