Pitfalls & Burns

Turbo Streams

Spray and Pray

Broadcasts wired into model callbacks go out on every save, to streams any page can subscribe to. An import becomes a storm, and one café's orders show up at another. Let the operation decide, and scope the streams.

app/views/pickup_boards/show.html.erb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<%= turbo_stream_from "pickup" %>

<% theme = cafe_theme(@cafe) %>

<div class="mx-auto max-w-2xl p-6">
  <h1 class="flex items-center gap-2 text-xl font-bold <%= theme[:title] %>">
    <%= render "cafes/icon", cafe: @cafe %>
    <%= @cafe.name %> — Customer Pickup
  </h1>

  <div id="pickup" class="mt-4 max-w-lg space-y-3">
    <%= render partial: "orders/pickup", collection: @orders, as: :order %>
  </div>
</div>
The original version, with one café. Orders reach the bar and ready orders reach the pickup board.
The pitfall version, with a second café. Orders land at both cafés, and an import arrives as thirty separate broadcasts.
The refactored version. Each café hears only its own orders, and an import arrives as one broadcast.

The scenario

We're building an app for a café. Baristas add orders and mark them ready from a bar screen, and customers watch a pickup board for their name. Both screens should update live, over Turbo Stream broadcasts.

The burn

turbo-rails gives us a one-line macro, so order.rb says broadcasts inserts_by: :prepend. The pickup board only shows orders that are ready, and no macro can express that, so we hand-roll an after_update_commit callback that prepends to a "pickup" stream when an order becomes ready. The views subscribe with turbo_stream_from "orders" and turbo_stream_from "pickup". With one café, it works.

Then two things happen. A second café signs up, and every order added or marked ready at one café appears on the other's screens as well. The stream names are global, so every page that subscribes hears everything. And product asks for a bulk import of mobile orders. order_imports_controller.rb creates thirty orders, the callback fires thirty times, and every connected screen receives thirty separate broadcasts for one click.

There's a quieter cost too. broadcasts sends only creates to the named stream; updates go to each record's own stream, so _order.html.erb has to subscribe every row to itself.

The fix

Answer "who gets what, and when?" deliberately.

Who: stream names carry the café. The bar subscribes to [cafe, :bar] and the pickup board to [cafe, :pickup], and the targets use dom_target to match.

When: the model stops deciding. The refactored order.rb has no callbacks. It keeps composer methods, broadcast_created and broadcast_ready, that know what each update looks like, and the controller calls the one it means. mark_ready only changes state, returning false if the order was already ready, so nothing is announced twice. The import knows it's one batch, so it calls Cafe#broadcast_new_orders once, sending one message with thirty rows instead of thirty messages with one.

Why it matters

A callback fires from every write path: imports, backfills, the console. It can't tell them apart, for the same reason we hesitate to send email from after_create. The split is the same as a mailer's: the model composes the message, and the caller decides whether and when to send it.

Scoping matters for more than noise. turbo_stream_from signs the stream name, so a page can only subscribe to streams the server rendered for it. Rendering the tag is the authorization step, and a bare name like "orders" grants access to everyone's orders.

We could reach for broadcasts_refreshes_to :cafe instead, and it would fix both symptoms. But each refresh becomes one full page request per connected client, which is fine for a few screens and expensive for many.

Read More