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/models/order.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
class Order < ApplicationRecord
  include ActionView::RecordIdentifier

  belongs_to :cafe

  validates :item, :name, presence: true

  scope :ready, -> { where(ready: true) }

  # The menu — the order form is a dropdown, not a free-text field.
  DRINKS = [
    "Americano", "Cappuccino", "Chai latte", "Cold brew", "Cortado",
    "Espresso", "Flat white", "Latte", "Macchiato", "Mocha"
  ].freeze

  # Thirty mobile orders keyed in as one batch — the bulk operation.
  MOBILE_ORDERS = [
    [ "Cappuccino", "Ada" ],    [ "Latte", "Bo" ],        [ "Flat white", "Cleo" ],
    [ "Espresso", "Dev" ],      [ "Mocha", "Esme" ],      [ "Cold brew", "Finn" ],
    [ "Chai latte", "Gus" ],    [ "Americano", "Hana" ],  [ "Macchiato", "Ines" ],
    [ "Cortado", "Jonas" ],     [ "Cappuccino", "Kit" ],  [ "Latte", "Lena" ],
    [ "Flat white", "Milo" ],   [ "Espresso", "Nadia" ],  [ "Mocha", "Omar" ],
    [ "Cold brew", "Pia" ],     [ "Chai latte", "Quinn" ], [ "Americano", "Rosa" ],
    [ "Macchiato", "Sami" ],    [ "Cortado", "Tess" ],    [ "Cappuccino", "Uri" ],
    [ "Latte", "Vera" ],        [ "Flat white", "Wes" ],  [ "Espresso", "Xiu" ],
    [ "Mocha", "Yara" ],        [ "Cold brew", "Zane" ],  [ "Chai latte", "Amara" ],
    [ "Americano", "Bram" ],    [ "Macchiato", "Cira" ],  [ "Cortado", "Dov" ]
  ].freeze

  # Broadcast composers: the model knows what an update looks like — stream
  # names, targets, partials, the dom_target symmetry. The operation that
  # calls them decides whether and when. Same split as a mailer.
  def broadcast_created
    broadcast_prepend_to [ cafe, :bar ],
      target: dom_target(cafe, :bar),
      partial: "orders/order"
  end

  def broadcast_ready
    broadcast_replace_to [ cafe, :bar ],
      target: dom_target(self, :row),
      partial: "orders/order"

    broadcast_prepend_to [ cafe, :pickup ],
      target: dom_target(cafe, :pickup),
      partial: "orders/pickup"
  end

  # The state transition, and nothing else — it stays out of the broadcasting
  # business, because changing a record and telling people about it are two
  # decisions and only the caller can make the second one. Returns false when
  # the order was already ready, so the caller knows there's nothing to
  # announce.
  def mark_ready
    return false if ready?

    update!(ready: true)
  end
end
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