Pitfalls & Burns

Turbo Streams

Lack of Context

Broadcast partials render with no request around them. Personalization written against Current shows the actor's "You" in every window when broadcast from a request, and crashes outside one, when the answer comes from a background job.

app/views/games/_banner.html.erb
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
<%# Data-out: every branch renders the same for every subscriber, from any
    origin — request or job. Names carry a player key; each window decides
    for itself whether that name is "You". %>
<div id="<%= dom_target(game, :banner) %>" class="mt-4 rounded <%= banner_bg(game) %> p-3 text-sm">
  <% if game.guessed? %>
    <p class="font-medium text-green-700">
      🎉
      <span data-controller="meta-text"
            data-meta-text-name-value="player-key"
            data-meta-text-match-value="<%= game.winner.key %>"
            data-meta-text-content-value="You"><%= game.winner.name %></span>
      guessed it — it was a <%= game.secret %>!
    </p>
  <% elsif game.stumped? %>
    <p class="font-medium text-gray-800">
      Out of questions — the agent wins. It was a <%= game.secret %>.
    </p>
  <% elsif (question = game.questions.pending.last) %>
    <p class="animate-pulse text-gray-500">
      Thinking about
      <span class="font-semibold <%= player_accent(question.player)[:name] %>"
            data-controller="meta-text"
            data-meta-text-name-value="player-key"
            data-meta-text-match-value="<%= question.player.key %>"
            data-meta-text-content-value="your"><%= question.player.name %>'s</span>
      question…
    </p>
  <% else %>
    <div data-controller="meta-reveal"
         data-meta-reveal-name-value="player-key"
         data-meta-reveal-match-value="<%= game.current_player.key %>">
      <p data-meta-reveal-target="fallback" class="text-gray-600">
        Waiting for
        <span class="font-semibold <%= player_accent(game.current_player)[:name] %>"><%= game.current_player.name %></span>
        to ask…
      </p>
      <div data-meta-reveal-target="matched" hidden>
        <p class="font-medium <%= player_accent(game.current_player)[:prompt] %>">Your turn — ask a yes/no question.</p>
        <%= form_with url: game_questions_path(game), scope: :question,
              class: "mt-2 flex items-center gap-2" do |form| %>
          <%= form.text_field :body, placeholder: "Is it…?", autocomplete: "off",
                class: "w-full rounded bg-white p-1.5 text-sm inset-shadow-sm" %>
          <%= form.button "Ask", class: "whitespace-nowrap rounded px-3 py-1.5 text-sm #{player_accent(game.current_player)[:button]}" %>
        <% end %>
      </div>
    </div>
  <% end %>
</div>
The pitfall version. Both windows say "You" for the asker, and the game stalls when the answer job fails.
The refactored version. Each window shows its own "You", its own time zone, and the ask form only for the player whose turn it is.

The scenario

We've built 20 Agentic Questions: two players in different places take turns asking yes/no questions of an AI agent that's thinking of something. The agent takes a few seconds to answer, so questions_controller.rb broadcasts the new question and a "Thinking…" banner right away, then hands off to answer_question_job.rb, which decides the verdict and broadcasts the answer.

We want the page to feel personal: "You" instead of your own name, "Your turn" with an ask form for whoever's up, and times shown in each player's time zone.

The burn

On page load everything is right. Over broadcasts, it falls apart in two ways.

_question.html.erb labels the asker "You" when they match Current.player, and formats the time in Current.player.time_zone. _banner.html.erb makes the same comparison to decide who sees the form. When the controller broadcasts the question, those partials render once, inside the asker's request, with the asker as Current.player, and that one rendering goes to everyone. The other player's window says "You asked" too.

Then the job runs. A background job has no request, so Current.player is nil. The banner survives but renders as if nobody is watching: no window gets the ask form, and the game stalls. The question partial calls time_zone on nil, the job raises ActionView::Template::Error, and the answer is never broadcast. A reload fixes everything, because a reload is a request.

The fix

Render what's true for everyone on the server, and personalize in the browser.

The refactored partials never touch Current. They ship the player's name tagged with that player's key, and the time as UTC in a <time> element. games/show.html.erb, which does have a request, writes the viewer's key into a <meta name="player-key"> tag.

Three small Stimulus controllers do the rest. local_time_controller.js reformats the <time> in the browser's own time zone, which the browser always knows. meta_text_controller.js replaces an element's text when a meta tag matches a value, turning your own name into "You". meta_reveal_controller.js uses the same comparison to reveal the ask form in place of "Waiting for…". Stimulus connects on elements inserted by streams, so broadcasts personalize themselves on arrival.

The controllers and the job barely change. The fix is in what the partials assume.

Why it matters

Legitimate broadcasts are usually async, and async work has no request by definition. So partials that go out over the wire can't depend on request state, and it's worth treating that as a rule for the whole team, since nothing in the framework enforces it.

The real question is where personalization happens and how many times we can afford to render. Refreshes give every subscriber their own full render with their own session. Streams give one render for everyone, which works when the payload is mostly the same and the differences are small enough for the browser.

Hiding the form is not authorization. Game#accepting_question_from? still checks the turn on the server.

Read More