Streaming an LLM answer with Turbo Streams, and the second broadcast that saves it
September 21, 2026
A completion takes several seconds and the reader stares at nothing for all of them. The fix is to
put each chunk on the screen as it arrives, and in Rails 8 that costs one line inside the streaming
block rather than a WebSocket layer.
The subscription is one line in the view
The page that shows a completion subscribes to the chat record itself:
<%=turbo_stream_from@chat%>
No channel class, no ApplicationCable::Channel subclass, no JavaScript. turbo_stream_from takes
any object and derives a signed stream name from it, and the job broadcasts to that same object.
Two developers can read the view and the job and see the connection without a third file explaining
the naming convention.
What the page also needs is a stable target to append into. The response partial renders a div
whose id is passed in, defaulting to ai_response, because a page showing several answers at once
needs one id per answer or two streams append into the same element and interleave.
Two details in four lines. Blank chunks are dropped, because providers emit chunks carrying only
metadata and each one would otherwise cost a broadcast that appends nothing. And the text is escaped
explicitly, which is the part worth stopping on: this is model output going straight into the DOM,
on a path where no view helper escapes anything for you. A model that emits a <script> tag, or
quotes one back from the user's own prompt, is otherwise appending markup to your page.
Solid Cable carries those broadcasts, so the transport is a Postgres table rather than Redis. For a
Rails 8 app that already runs Solid Queue for the job, the streaming feature adds no new
infrastructure at all.
The second broadcast, and why a reload needs it
Appending is an event. It changes the DOM of every browser currently subscribed, and it writes
nothing anywhere. Reload the page halfway through and the answer is gone, because the appends
happened to a document that no longer exists and the database has the message only when the model
has finished with it.
So the job broadcasts once more when the stream ends, replacing the entire target with the assistant
message read back from the database:
Now the page has two ways to be correct. A browser watching live sees chunks and then one replace.
A browser arriving afterwards renders the same partial from the same message on first load, since
show reads the last assistant message into @answer. A reload mid-answer loses the partial text
and gets the rest when the stream finishes, which is the failure mode nobody notices because it
looks like a slightly slow answer rather than a bug.
Hiding the spinner without polling
The remaining piece is cosmetic and is worth the twenty lines. The generating hint has to disappear
when the first token lands, and the page has to follow the text as it grows. Neither event is
something the server can tell the page about, since the server's whole contribution is appends into
a div.
MutationObserver fires on any DOM change inside the target, whatever caused it, so it needs to
know nothing about Turbo. The update handler hides the spinner once the target has text and calls
scrollIntoView on it. The observer is disconnected in disconnect(), which matters more than it
looks: Turbo caches pages and restores them, and an observer left attached to a detached element is
a leak that accumulates over a browsing session rather than crashing anything.
What decides whether the stream is even possible
Which model runs the completion is chosen before the job opens a connection, and the choice can
change again halfway through. A provider that rate limits after four chunks leaves those four chunks
on the screen, and the backup model then starts its own answer from the first word into the same
DOM target. Appending the second on top of the first produces one paragraph made of two answers,
which reads as a model losing its train of thought rather than as an error.
The job handles that with a broadcast_replace_to that empties the target before the retry begins,
which is the only reason a mid-stream failover is invisible to the reader.
The two fallbacks covers which
four exception classes reach that path, and the separate quota check that picks a model before any
of this happens.
What this page does not cover
Cancelling a stream in flight. The chats table carries a cancelled boolean and no code in this
flow reads it, so a user who closes the tab pays for the rest of the completion anyway. Stopping a
provider call already in progress means a check inside the streaming block and a way to signal it
from a controller, and neither exists here.
Nor does it cover backpressure. Every chunk is a broadcast, a provider emitting many small chunks
produces many small Postgres writes through Solid Cable, and nothing batches them. At the traffic
this is written for that is fine, and it is the first thing that would need measuring if it were
not.
A completion takes several seconds and the reader stares at nothing for all of them. The fix is to put each chunk on the screen as it arrives, and in Rails 8 that costs one line inside the streaming block rather than a WebSocket layer.
The subscription is one line in the view
The page that shows a completion subscribes to the chat record itself:
No channel class, no
ApplicationCable::Channelsubclass, no JavaScript.turbo_stream_fromtakes any object and derives a signed stream name from it, and the job broadcasts to that same object. Two developers can read the view and the job and see the connection without a third file explaining the naming convention.What the page also needs is a stable target to append into. The response partial renders a
divwhose id is passed in, defaulting toai_response, because a page showing several answers at once needs one id per answer or two streams append into the same element and interleave.One broadcast per chunk
ruby_llmstreams by yielding chunks to the block passed toask. The whole streaming path is that block:Two details in four lines. Blank chunks are dropped, because providers emit chunks carrying only metadata and each one would otherwise cost a broadcast that appends nothing. And the text is escaped explicitly, which is the part worth stopping on: this is model output going straight into the DOM, on a path where no view helper escapes anything for you. A model that emits a
<script>tag, or quotes one back from the user's own prompt, is otherwise appending markup to your page.Solid Cable carries those broadcasts, so the transport is a Postgres table rather than Redis. For a Rails 8 app that already runs Solid Queue for the job, the streaming feature adds no new infrastructure at all.
The second broadcast, and why a reload needs it
Appending is an event. It changes the DOM of every browser currently subscribed, and it writes nothing anywhere. Reload the page halfway through and the answer is gone, because the appends happened to a document that no longer exists and the database has the message only when the model has finished with it.
So the job broadcasts once more when the stream ends, replacing the entire target with the assistant message read back from the database:
Now the page has two ways to be correct. A browser watching live sees chunks and then one replace. A browser arriving afterwards renders the same partial from the same message on first load, since
showreads the last assistant message into@answer. A reload mid-answer loses the partial text and gets the rest when the stream finishes, which is the failure mode nobody notices because it looks like a slightly slow answer rather than a bug.Hiding the spinner without polling
The remaining piece is cosmetic and is worth the twenty lines. The generating hint has to disappear when the first token lands, and the page has to follow the text as it grows. Neither event is something the server can tell the page about, since the server's whole contribution is appends into a div.
A Stimulus controller watches the target instead:
MutationObserverfires on any DOM change inside the target, whatever caused it, so it needs to know nothing about Turbo. The update handler hides the spinner once the target has text and callsscrollIntoViewon it. The observer is disconnected indisconnect(), which matters more than it looks: Turbo caches pages and restores them, and an observer left attached to a detached element is a leak that accumulates over a browsing session rather than crashing anything.What decides whether the stream is even possible
Which model runs the completion is chosen before the job opens a connection, and the choice can change again halfway through. A provider that rate limits after four chunks leaves those four chunks on the screen, and the backup model then starts its own answer from the first word into the same DOM target. Appending the second on top of the first produces one paragraph made of two answers, which reads as a model losing its train of thought rather than as an error.
The job handles that with a
broadcast_replace_tothat empties the target before the retry begins, which is the only reason a mid-stream failover is invisible to the reader. The two fallbacks covers which four exception classes reach that path, and the separate quota check that picks a model before any of this happens.What this page does not cover
Cancelling a stream in flight. The
chatstable carries acancelledboolean and no code in this flow reads it, so a user who closes the tab pays for the rest of the completion anyway. Stopping a provider call already in progress means a check inside the streaming block and a way to signal it from a controller, and neither exists here.Nor does it cover backpressure. Every chunk is a broadcast, a provider emitting many small chunks produces many small Postgres writes through Solid Cable, and nothing batches them. At the traffic this is written for that is fine, and it is the first thing that would need measuring if it were not.