DEV Community

Cover image for Turbo Streams on Rails
David Colby
David Colby

Posted on • Originally published at

Turbo Streams on Rails

Turbo, part of the Hotwire tripartite, helps you build modern, performant web applications with dramatically less custom JavaScript.

Turbo is composed of Turbo Drive, Turbo Frames, Turbo Streams, and Turbo Native. Each is a valuable piece of the puzzle but today we’re going to focus on Turbo Streams.

Turbo Streams “deliver page changes over WebSocket, Server-Sent Events, or in response to form submissions” and they might be the most exciting part of the Turbo package.

Streams allow you to deliver fast and efficient user experiences with minimal effort and, for Rails developers, you’ll find that streams fit comfortably into your existing toolkit.

With just a few lines of code, you can send tiny snippets of HTML over the wire and and use those snippets to update the DOM with no custom JavaScript.

For fancier use cases, you can broadcast changes over a WebSocket connection to keep every interested user updated without a refresh. No custom JavaScript to write, just HTML and a little bit of Ruby.

What’s missing?

One of the challenges of new web development tools is that documentation can be sparse, and that’s no different with Hotwire and Turbo.

Official documentation exists, and the introduction video demoing Hotwire does a good job showing how Hotwire works, but comprehensive guides and documentation clearly outlining the various options you have when using Hotwire aren’t all the way there yet.

Streams are powerful but, with limited documentation, getting up to speed can be daunting.

So, today I’m going to share a bit of what I’ve learned from using Turbo Streams in a variety of applications, from small experiments to production-grade applications, to help you get value from Turbo Streams in your Rails application faster.

If you aren’t using Rails, some of the core concepts might still be helpful, but my focus will be on using streams within a Rails application via the turbo-rails gem.

Let’s dive in.

What's a Turbo Stream?

At their core, streams allow you to send snippets of HTML to the browser to make small changes to the DOM in response to events that happen on the server.

The basic structure of a Turbo Stream is an HTML snippet that looks like this:

<turbo-stream action="action_to_take" target="element_to_update">
    <div id="element_to_update">
      <!-- Some more html -->
Enter fullscreen mode Exit fullscreen mode

The <turbo-stream> element always includes an action, from the following possibilities:

  • append
  • prepend
  • before
  • after
  • replace
  • update
  • remove

In addition to an action, a target (or targets) must be provided.

Inside of the stream element, the HTML to render gets wrapped in a <template> tag.

Turbo’s JavaScript processes these HTML snippets by reading the action and target from the stream tag and inserting (or removing) the wrapped HTML as appropriate.

Turbo Streams can be sent in response to a direct request from a browser (like submitting a form) or broadcast to subscribers over a WebSocket connection.

With turbo-rails, we have the tools we need to take either path, and we’ll look at both methods in detail next.

Turbo Streams in controllers

The simplest way to get started with Turbo Streams is to have a controller respond to turbo_stream requests and render a turbo_stream response in return.

This approach looks something like this:

# players_controller.rb
  def create
    @player =

    respond_to do |format|
        format.html { redirect_to @player, notice: "Player was successfully created." }
        format.html { render :new, status: :unprocessable_entity }

# app/views/players/create.turbo_stream.erb
<%= turbo_stream.replace "players_form" do %>
  <%= render partial: "new_player" %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

In this example, a user is attempting to save a new player record in the database.

When the save is successful and the request format is turbo_stream, we use Rails’ implicit rendering to respond with create.turbo_stream.erb which renders a Turbo Stream response with a replace action targeting the players_form element.

The turbo_stream method in the create view generates the necessary <turbo-stream> tag and wraps the content within the turbo_stream block in a <template> tag.

The response back to the browser will look like this:

<turbo-stream action="replace" target="players_form">
    <!-- Content of new_player.html.erb partial -->
    <turbo-frame id="players_form">
      <a href="/players/new">New Player</a>
Enter fullscreen mode Exit fullscreen mode

Turbo’s JavaScript then steps in, processes the response, and updates the DOM.

Magically, we don’t need to make any adjustments to the failure path in our controller above. Even though the form submission is a turbo_stream request, Rails falls back to responding with HTML when a turbo_stream format to respond with does not exist.

Note that this approach only works with form submissions with a method of POST, PUT, PATCH, or DELETE. By design, GET requests and streams don’t interact.

Rendering inline

For simple responses, we don’t need dedicated .erb files.

Instead we can render the turbo_stream response inline in the controller, like this:

def create
  @player =

  respond_to do |format|
      format.html { redirect_to @player, notice: "Player was successfully created." }
      format.turbo_stream { render turbo_stream: turbo_stream.replace('players_form', partial: 'new_player') }

      format.html { render :new, status: :unprocessable_entity }
Enter fullscreen mode Exit fullscreen mode

There’s no difference between inline rendering and rendering from a template, for simple replace and append actions you can use whichever feels better.

Updating multiple elements at once

The create.turbo_stream.erb file we saw earlier renders a single <turbo-stream> tag, but we aren’t limited to updating a single stream per response. We can update multiple elements at once by simply adding them to our turbo_stream.erb file:

<%= turbo_stream.replace "players_form" do %>
  <%= render partial: "new_player" %>
<% end %>
<%= turbo_stream.append "players" do %>
  <%= render partial: "player", locals: { player: @player } %>
<% end %>
Enter fullscreen mode Exit fullscreen mode

Now after a successful form POST, we’ll send back a response with two <turbo-stream> tags, each of which will be processed by Turbo’s JavaScript on the front end.

If you really dislike creating turbo_stream templates, you can also render multiple Streams inline in the controller like this:

format.turbo_stream do
  # Pass an array of turbo_streams to the render call
  render turbo_stream: 
      turbo_stream.replace('players_form', partial: 'new_player'),
      turbo_stream.append('players', partial: 'player', locals: { player: @player })
Enter fullscreen mode Exit fullscreen mode

Should you do this? Probably not, but it works.

Broadcasting changes

So far, we’ve seen rendering streams from controller actions. This method works great for dealing with form submissions made by individual users; however, there’s another method of broadcasting updates to a wider audience that you can call on when needed.

As we saw in the Hotwire introduction video, stream broadcasts sent from the controller only update the page that made the original request. If other people are viewing the same page, they won’t see the update until they refresh the page.

If your use case requires every interested user to see changes as they happen, you can use broadcasts from the turbo-rails gem to send streams to every subscriber at once, in real time.

The basic syntax for these updates looks like this, in your model:

# app/models/player.rb
after_create_commit { broadcast_append_to('players') }
Enter fullscreen mode Exit fullscreen mode

Here we’re using a callback, fired each time a new player is created, to broadcast the newly created player to the players channel.

Broadcast options

By default, the Stream target will be set to the plural name of the model; however you can override the target as needed by passing in a target to the method, like this:

after_create_commit { broadcast_append_to('players', target: 'players_list') }
Enter fullscreen mode Exit fullscreen mode

Broadcasting from the model will attempt to use to_partial_path to guess the name of the partial that should be rendered.

For our examples so far, if we have a partial in app/views/players named _player.html.erb that partial will be used. As with targeting, you can override the partial like this:

after_create_commit { broadcast_append_to('players', partial: 'a_special_player') }
Enter fullscreen mode Exit fullscreen mode

When streaming in this manner, you must create a WebSocket connection to the channel you’re broadcasting updates on.

To do that, you can add <%= turbo_stream_from "some_channel" %> to the view.

The name of the channel passed to turbo_stream_from must match the name of the channel passed to broadcast_action_to , otherwise your updates will get lost in space.

broadcast_action_to vs. broadcast_action_later_to

In the source code for broadcastable, a comment at the top of the file advises us to use broadcast_action_later_to instead of broadcast_action_to .

Using _later methods moves the stream broadcast into a background job, which means that broadcasts can happen asynchronously, moving the potentially expensive rendering work out of the web request.

Since we want everything to be fast, not slow, we’ll use broadcast_action_later_to, like this:

after_create_commit { broadcast_append_later_to('players') }
Enter fullscreen mode Exit fullscreen mode

The only exception to the later rule is delete actions. broadcast_remove_to simply removes an element from the DOM without rendering a template and so does not need to be moved into a background job. To reinforce this exception, broadcast_remove_later_to is not defined and calling it will throw an undefined method error.

Broadcasting from a controller

So far we’ve seen that we can render a Turbo Stream response from a controller action and broadcast from a model.

Another, less common, path is to call broadcast_append|remove|replace on an instance of a model from within a controller (or a service, or anywhere else you like), like this:

Enter fullscreen mode Exit fullscreen mode

This does the same thing as calling this method in a callback in the model, meaning the append will broadcast to all users subscribed to the players channel.

More magical methods

So far we’ve been adding _to at the end of our broadcast methods and explicitly passing in the channel name. If you like magic, you can omit the _to from your broadcasts and just use broadcast_append|remove|replace.

Using this form requires a channel streaming from the model instance, like the below.

# player.rb
after_update_commit { broadcast_replace }

# _player.html.erb
<%= turbo_stream_from @player %>
Enter fullscreen mode Exit fullscreen mode

To type even less, you can add broadcasts_to to your model, like this:

# player.rb
broadcasts_to ->(_) { 'players' }
Enter fullscreen mode Exit fullscreen mode

Here we’re using the non-standard stream name of players. If we’re using the model instance as the stream name, we can get down to a single magic word:

# player.rb
Enter fullscreen mode Exit fullscreen mode

Both broadcasts and broadcasts_to automatically add broadcasts on create, update, and delete commits to the model, as seen here

In practice, these magic methods often don’t add much value since they rely so much on magical naming convention.

The magic fails when attempting to, for example, append a newly created record to an index list. In that scenario, our stream won’t be tied to an instance of the class, and so we’ll need to use the long form version of broadcast_action_to, like we saw above.

When in doubt, just use the longer, slightly less magical methods. I'm sharing these magic methods because they're commonly used in guides and feature prominently in the announcement video so you'll likely find them in the wild for years to come. If your use case allows you to use them, go for it, but the longer versions work just fine too.


Scope your channels!

In most of the examples I’ve shown so far, we are broadcasting to streams with hardcoded strings for names (“players”). This works fine; however, in most web applications resources aren’t globally available. Account 123 should only see updates on their account, not every account.

You can avoid streaming updates to the wrong user by ensuring you’re broadcasting to properly scoped channels and subscribing users to those scoped channels.

In the players example code we’ve used so far, we can imagine that a player belongs to a team, and updates to those players should be broadcast on the team channel.

To implement this in our code, it might look like this:

# teams/show.html.erb
<%= turbo_stream_from @team %>

# player.rb
belongs_to :team

after_create_commit { broadcast_append_later_to(team) }
after_destroy_commit { broadcast_remove_to(team) }
Enter fullscreen mode Exit fullscreen mode

With this in place, the team show page subscribes to updates related to that specific team, keeping data from leaking between different teams. The same concept can be applied to scope channels by user or account, as needed.

Note that this scoping is only necessary for broadcasts sent over websocket. If your controller renders a turbo_stream response, only the client that made the initial request will receive the update. There is no risk of data leaking in this manner when responding to a form submission with a turbo stream template.

Error handling

When you render a turbo_stream from a controller action and the DOM doesn’t have a matching target element to update… nothing happens. There’s no error anywhere, the stack trace indicates that the partial was rendered, and the DOM doesn’t update.

This is normal, expected behavior - there wasn’t an error, there just wasn’t anything to change in the DOM - but it can be confusing when you’re getting started and the lack of errors can make troubleshooting more difficult.

If you aren’t getting errors, the turbo_stream response is showing in the server logs, and nothing is updating the page, the problem is (almost) always that the target passed to <turbo-stream> isn’t matching any elements in your page’s markup. Start by checking what you're rendering on the page, and compare that to the target passed to the stream.

Turbo streams don’t need turbo frames

Based on the early guides and documentation, some folks (or maybe just me) thought that turbo frames and streams were tightly linked, and that frames were required for streams to work.

In reality, streams don’t care about frames at all.

Streams can target any old DOM id you like and you can build an application entirely without frames and still get plenty of value from streams. Often times you'll be targeting a turbo frame with a stream, but you're free to target any element you like.

Wrapping up

Turbo is an incredibly powerful tool, and the tight integration between Rails and Turbo means that forward-looking Rails developers should be exploring ways to bring Turbo into their applications.

Using streams with turbo-rails opens up a world of possible user experiences for Rails developers that previously meant more work, more effort, and more code to maintain.

In production applications, I have seen form submission round trips reliably complete in less than 200ms with turbo streams, all while writing code that is easy to understand, maintain, and scale as application requirements change.

Hopefully what I've shared here helps you feel a little more comfortable thinking about streams, and how streams can fit into your existing code base. As always, thanks for reading!

More resources

Ready to dive deeper?

  • Spend some time with turbo-rails source code
  • Spend some time with the turbo source code
  • Dive into the docs
  • Read some practical guides, like this guide on building a live search interface, or this one on handling modal form submissions
  • GoRails has published a variety of Hotwire-specific guides

Top comments (4)

airblade profile image
Andy Stewart • Edited

Very useful – this would have saved me a lot of time had I read it before I started grappling with turbo streams!

davidcolbyatx profile image
David Colby

Thanks, Andy, glad you enjoyed it!

sadiqmmm profile image
Mohammed Sadiq

Really nice post.

davidcolbyatx profile image
David Colby

Thanks, Mohammed, glad you enjoyed it!