Turbo Stream actions, and the pair that breaks the page
Turbo's stream actions are eight functions in one JavaScript object, and almost every argument about them comes from people holding different lists. Some lists have nine entries because morph is on them. Some have a redirect that does not exist. The authoritative list is short and it is readable in about a minute.
Everything below is checked against turbo-rails 2.0.23, the bundled app/assets/javascripts/turbo.js
that ships inside it, and the Turbo handbook. The Ruby side is Rails 8.1.3.1.
The eight actions, read out of turbo.js
StreamActions is a plain object literal, and its keys are the complete set of action values a
<turbo-stream> element can carry out of the box:
const StreamActions = {
after() { ... },
append() { ... },
before() { ... },
prepend() { ... },
remove() {
this.targetElements.forEach((e => e.remove()));
},
replace() { ... },
update() { ... },
refresh() { ... }
};
Eight keys. after and before insert the template's content as a sibling of the target, on either
side of it. append and prepend insert it as the last or first child. remove deletes the target
and, being the one action with nothing to render, emits no <template> at all, which
Turbo::Streams::ActionHelper#turbo_stream_action_tag handles with
action.to_sym.in?(%i[ remove refresh ]) ? "" : tag.template(...). replace and update are the
subject of the next section. refresh reloads the page through session.refresh.
The Rails helper mirrors this one to one. Turbo::Streams::TagBuilder defines remove, replace,
before, after, update, append, prepend and refresh, plus an _all variant of each
insertion action that writes targets="<css selector>" instead of target="<dom id>", plus a
generic action(name, target, ...) escape hatch. There is no ninth thing hiding in the gem.
Replace against update, and the two ways to get it wrong
replace and update differ by one element: the target itself. Both branches are four lines of
JavaScript and reading them settles the question permanently.
replace() {
const method = this.getAttribute("method");
this.targetElements.forEach((targetElement => {
if (method === "morph") {
morphElements(targetElement, this.templateContent);
} else {
targetElement.replaceWith(this.templateContent);
}
}));
},
update() {
const method = this.getAttribute("method");
this.targetElements.forEach((targetElement => {
if (method === "morph") {
morphChildren(targetElement, this.templateContent);
} else {
targetElement.innerHTML = "";
targetElement.append(this.templateContent);
}
}));
}
replaceWith swaps the element out of the document. Whatever you render has to bring the id back
with it, or nothing in the page answers to that id any more. Every subsequent stream aimed at it
lands on the failure described in the next section.
innerHTML = "" keeps the element and empties it. Render a partial whose root element carries the
same id and you get <div id="ai_response"><div id="ai_response">...</div></div>, two elements with
one id, and getElementById will keep answering with the outer one. The page looks right until
somebody targets it again and the update goes one layer too shallow.
So the rule is about the partial, not about the action. A partial that opens with the id belongs to
replace. A partial that opens with content belongs to update. The handbook states the
consequence of keeping the element: with update, "any handlers bound to the element would be
retained. This is to be contrasted with the 'replace' action above, where that action would
necessitate the rebuilding of handlers". If a Stimulus controller is declared on the target element
itself, replace disconnects and reconnects it and update does not, which matters for any
controller holding state across the swap, and is one of the cases
Stimulus controllers in practice has to account for.
The turbo-stream that does nothing and says nothing
targetElementsById is where a mistyped or missing id goes to die:
get targetElementsById() {
const element = this.ownerDocument?.getElementById(this.target);
if (element !== null) {
return [ element ];
} else {
return [];
}
}
An empty array means forEach runs zero times. No exception, no console warning, no failed request,
no difference at all in the network tab: the <turbo-stream> arrives, renders nothing, and removes
itself from the document in disconnect(). Debugging this from the browser is unpleasant, because
every observable signal says the broadcast worked.
Two mistakes produce it. A replace whose partial dropped the id, as above. And a target that was
never in the page for this viewer, which is the common one on broadcast: the element lives inside a
lazy Turbo Frame that has not loaded, or inside a branch of the template that this user's
permissions did not render. Streams are not queued for later. A broadcast arriving before its target
exists is simply lost, and the related trap of a frame whose id does not match what the server
returned is
what an empty Turbo Frame is actually telling you.
The cheap check, before you go looking for anything subtler, is document.getElementById("...") in
the console on the page that is failing to update.
Morph is a method, not an action
Morphing is the fifth action on a lot of people's lists and it is not an action. In the source above
it is a branch inside replace and update, selected by this.getAttribute("method"), dispatching
to morphElements for replace and morphChildren for update. The morphing itself is Idiomorph,
vendored into turbo.js.
On the Ruby side, method: is a keyword on exactly those two builders:
turbo_stream.replace clearance, "<div>Morph the dom target</div>", method: :morph
turbo_stream.update clearance, "<div>Morph the dom target</div>", method: :morph
Emitting action="morph" produces a <turbo-stream> that throws. performAction looks the name up
in StreamActions, finds nothing, and raises unknown action with the element's opening tag as the
prefix.
Refresh, and the request-id that stops the loop
refresh has no target and no template. turbo_stream_refresh_tag builds it, and the interesting
part is the default argument:
def turbo_stream_refresh_tag(request_id: Turbo.current_request_id, **attributes)
turbo_stream_action_tag(:refresh, "request-id": request_id.presence, **attributes)
end
The request-id attribute is how a client recognises a refresh it caused itself and declines to act
on it. Without it, the person who submitted the form gets a full page reload on top of the response
they were already rendering. broadcast_refresh_later_to carries the same default forward, and
refresh_debouncer_for collapses a burst of refreshes to the last one, which is the whole design:
page refreshes are for mass updates where per-element fidelity is not worth the code.
Choosing refresh over targeted actions is a real trade and the Broadcastable docs name it: "the
fidelity you can reach is often not as high as with targeted stream actions since it renders the
entire page again". On a list view where four things changed, that is the right call. On a token by
token stream it is absurd.
There is no turbo stream redirect action
Redirect appears in the autocomplete for this topic and does not appear in StreamActions. Emitting
action="redirect" gets you the unknown action throw from the previous section, so the question is
what you meant instead, and there are two different answers.
If a form submission is what triggered it, you do not want a stream at all. The handbook is explicit:
"After a stateful request from a form submission, Turbo Drive expects the server to return an HTTP
303 redirect response, which it will then follow and use to navigate and update the page without
reloading." So redirect_to dashboard_path, status: :see_other is the whole answer, and the reason
Rails scaffolds write status: :see_other on the destroy action. The documented exception is
4xx and 5xx, which is why validation failures render with 422 Unprocessable Content rather than
redirecting.
Registering a redirect action of your own
A navigation that has to come from a broadcast, over a WebSocket, arriving at a page nobody just submitted, has no 303 to ride on, so you register the action yourself. Turbo exposes the object:
// app/javascript/application.js
Turbo.StreamActions.redirect = function () {
Turbo.visit(this.getAttribute("url"), { action: "advance" })
}
render turbo_stream: turbo_stream.action(:redirect, dashboard_url)
Read turbo_stream.action(:redirect, dashboard_url) carefully before copying it. The second
positional argument of TagBuilder#action is target, so that call writes the URL into the
target attribute, not a url attribute, and the handler above would have to read this.target
instead. Pick one and make the JavaScript and the Ruby agree. Prepending a small module onto
Turbo::Streams::TagBuilder so you can write turbo_stream.redirect(url) with a real url:
attribute is worth the six lines if you use it more than once.
Copies of this snippet on the internet pass { frame: "_top", action: "advance" }. Read
Session#visit: options.frame is fed straight to document.getElementById, and the frame branch
runs only when that lookup returns a FrameElement. There is no element with the id _top, so the
lookup returns null and the code falls through to an ordinary visit, which is what you wanted, by
accident. Pass a real frame id there or pass nothing.
The cost of the custom action is that it is now your API. Nothing in the handbook covers it, it
does not appear in Turbo::Broadcastable, and the next person to read the controller has to go find
the JavaScript. Reach for the 303 whenever a 303 is possible.
What broadcasting looks like in a job
Streams stop being a rendering convenience and start changing how you build when the HTML comes from
a process that has no request. The boilerplate this site sells streams LLM tokens that way. Here is
the whole broadcast surface of app/jobs/ai_completion_job.rb, one append per chunk:
def stream(chat, chunk, target)
text = chunk.content.to_s
return if text.blank?
Turbo::StreamsChannel.broadcast_append_to(chat, target: target, html: ERB::Util.html_escape(text))
end
and one replace when the model is done:
def finalize(chat, target, template_key = nil)
Turbo::StreamsChannel.broadcast_replace_to(
chat, target: target, partial: "ai/completions/response",
locals: { content: chat.messages.where(role: "assistant").last&.content, target: target, template_key: template_key }
)
end
Two actions, and the division of labour between them is the point. append pushes raw escaped text
into #ai_response as fast as the provider emits it, with no partial rendered and no markup, because
half a Markdown table is not renderable. replace then throws that accumulated text away and
substitutes the rendered answer once, which is why the partial re-emits the id it is replacing:
<% target = local_assigns.fetch(:target, "ai_response") %>
<div id="<%= target %>" class="prose max-w-none text-gray-800">
The failover path in the same job uses broadcast_replace_to a third time, with content: nil, to
wipe a half streamed answer before regenerating on the backup model. Resetting a streaming target is
a replace with an empty partial, not a remove, because remove would take the id with it.
What broadcasting looks like from a service object
app/services/notifications/deliver.rb in the same codebase broadcasts two actions per
notification, to one stream, from an ordinary service object called by controllers:
stream = [ recipient, :notifications ]
Turbo::StreamsChannel.broadcast_prepend_to(
*stream,
target: "notifications",
partial: "notifications/notification",
locals: { notification: notification }
)
Turbo::StreamsChannel.broadcast_replace_to(
*stream,
target: "notifications_badge",
partial: "notifications/badge",
locals: { count: recipient.notifications.unread.count }
)
Two things there are worth stealing. The stream is [ recipient, :notifications ], splatted, so it
matches turbo_stream_from Current.user, :notifications in the layout exactly; a stream name
assembled slightly differently at the two ends is the other silent failure in this system, and it
fails the same way, with a subscription that receives nothing forever.
And the second broadcast exists because the unread count is derived state that lives somewhere else in the DOM. Prepending the notification does not change the badge. Any broadcast that changes a count, a total or an empty state is really two broadcasts, and forgetting the second one is how a page ends up showing three rows above a badge reading zero.
Why a model callback is usually the wrong place
Turbo::Broadcastable makes after_create_commit :broadcast_later look like the default, and the
gem's own documentation immediately qualifies it: "you should use the _later versions of everything
except for remove when broadcasting within a real-time path, like a controller or model, since all
those updates require a rendering step, which can slow down execution."
Both calls in Notifications::Deliver above are the synchronous form, so a request that creates a
notification renders two partials before it can respond. For a badge and one row that is a
millisecond of ERB and nobody will ever measure it. For a partial that queries, or a callback firing
on a bulk update of four hundred rows, the same pattern renders four hundred times on the thread
someone is waiting on. The fix is broadcast_prepend_later_to, which enqueues
Turbo::Streams::ActionBroadcastJob and moves the rendering to a worker.
Note that Turbo::Streams::Broadcasts defines no broadcast_remove_later_to. Removal needs only
the dom id, there is nothing to render, and the gem declines to give you a job for it.
The stronger reason to keep broadcasts out of model callbacks has nothing to do with speed. A
callback fires for every write, including the console session you used to fix one row, the seeds,
the backfill, and the test that creates a fixture. A service object fires when the application
decided something happened. Turbo::Broadcastable ships a class method for exactly this, so you can
write Message.suppressing_turbo_broadcasts { ... } around bulk work, and needing that block is a
signal the broadcast was attached to the wrong event.
The broadcast that fires before the transaction commits
Broadcasts leaving before their data lands is the failure that survives code review, because the code reads correctly and only the ordering is wrong.
The synchronous case is the easy half. broadcast_replace_to inside an open transaction renders and
publishes immediately over Action Cable. Roll the transaction back afterwards and subscribers are
holding HTML for a record that does not exist. Worse, the HTML usually contains a link or a lazy
frame, so the browser follows it and your log fills with ActiveRecord::RecordNotFound on ids that
were never committed.
The _later case is the half that hides. perform_later inside a transaction does not wait for the
commit by default:
# activejob-8.1.3.1/lib/active_job/enqueuing.rb
class_attribute :enqueue_after_transaction_commit, instance_accessor: false,
instance_predicate: false, default: false
Nothing in load_defaults 8.1 flips that, so on a stock Rails 8.1 app the job row is written while
your transaction is still open. A Solid Queue worker in another process picks it up, tries to
resolve the record from its GlobalID, and cannot see it. What happens next is one line at the top of
the job:
class Turbo::Streams::ActionBroadcastJob < ActiveJob::Base
discard_on ActiveJob::DeserializationError
Discarded. No retry, no failed execution row, no error report. The update simply never arrives, on maybe one enqueue in a thousand, on whichever machine happened to be fast.
Read that class_attribute line yourself rather than trusting the surrounding documentation, which
is the part that caught me out: the doc comment on perform_later in the same file describes the
deferral as implicit, and the attribute defaults to false. Check
ActiveJob::Base.enqueue_after_transaction_commit in a console on your own application and believe
the console.
Three fixes, in order of how much they change. Use after_create_commit and
after_update_commit rather than after_create and after_update, which is free and covers the
callback case. Move the broadcast out of the transaction into the service object that owns the
operation, which is where Notifications::Deliver already sits. Or set
self.enqueue_after_transaction_commit = true, on ApplicationJob or on a single job class, and
accept that jobs enqueued outside any transaction are unaffected while everything inside one now
waits for a commit that a long transaction may hold for a while.
Where these broadcasts actually travel
Action Cable carries every broadcast_*_to call above, and in this codebase the Action Cable adapter
is Solid Cable, with polling_interval: 0.1.seconds and message_retention: 1.day in
config/cable.yml for production. Broadcasts are rows in a Postgres table that subscribers poll,
which is the same trade, with the same reasoning, as the queue and the cache:
running Rails 8 without Redis works through what that costs.
What this post does not cover
Turbo Frames are absent here except where a missing frame eats a stream. Frames answer a different question, namely which part of a page a normal request replaces, and streams answer what an out of band update does to a page nobody requested.
Also absent: broadcast_render_to and broadcast_render_later_to, which render a
.turbo_stream.erb template containing several actions in one message, and are the right tool when
one event changes four places; signed stream names and the Turbo::StreamsChannel#subscribed
rejection path, which is the authorization story for broadcasts and deserves its own page; and
turbo_stream_from ..., channel: CustomChannel, the hook for putting your own subscription_allowed?
in front of a stream.
No benchmark appears above. The rendering cost of a synchronous broadcast is real and it is entirely a function of what your partial does, so a number measured against this site's partials would tell you nothing about yours.
Comments
No comments yet. Be the first.