Avoiding a race condition on initial subscription in turbo-rails

I often seem to have a model where on creation, the user sees a placeholder page with a spinner, a background job fills in some data on that model, and then the placeholder page refreshes with the latest data. Might be generating a slow data report, or contacting an external service to notify it about the creation, or whatever.

Say we’re creating a BlogPost, and need to fill in BlogPost#short_url from some-url-generator.com. After creation, a background job is queued up and we render blog_posts/show.html to the user:

<div id="<%= dom_id(blog_post) %>">
  Title: <%= blog_post.title %>
  Short URL:
  <% if blog_post.short_url %>
    <%= blog_post.short_url %>
  <% else %>
    loading...
  <% end %>
</div>
<%= turbo_stream_from blog_post %>

A few seconds later, the background job completes and updates the page via broadcast_replace_to, showing our amazing new short-url.

The problem comes when the background job completes too fast, after we’ve rendered show.html but before the user has received the response and started streaming. broadcast_replace_to is just going into a black hole with no subscribers, and the user is sat staring at the “loading…” text forever.

I’ve run into this a couple of times and never found a good fix. My awful workaround at the moment is this:

-  def broadcast_action_later_to(*streamables, action:, target: nil, targets: nil, attributes: {}, **rendering)
+  def broadcast_action_later_to(*streamables, action:, target: nil, targets: nil, attributes: {}, repeat_after: nil, **rendering)
     Turbo::Streams::ActionBroadcastJob.perform_later \
       stream_name_from(streamables), action: action, target: target, targets: targets, attributes: attributes, **rendering
+    if repeat_after
+      Turbo::Streams::ActionBroadcastJob.set(wait: repeat_after).perform_later \
+        stream_name_from(streamables), action: action, target: target, targets: targets, attributes: attributes, **rendering
+    end
  end

which adds a repeat_after option to Turbo::Broadcastable. This broadcasts the replacement immediately as normal, and then sends a duplicate replacement after a second or two to make sure the client has had chance to set up its subscription.

Ideally turbo-rails might be able to maintain a short-lived buffer in redis so that any missed messages could be replayed, but actually implementing that seems kind of hard. Any other suggestions, or do I need to just give up on turbo-streams for this and go back to old-fashioned polling?

I would look at establishing the web socket stream before the form submission. You could use the current user or session ID, or use a uniq identifier for each form

<% blog_guid = SecureRandom.uuid %>

<%= form_with(model: @post, local: true) do |form| %>
  ...
  <%= form.hidden_field :blog_guid, value: blog_guid %>
<% end %>

<%= turbo_stream_from blog_guid %>

Interesting idea with the guid, thanks. I think there’s still a race condition there, but it’s probably smaller due to making sure the subscription is set up before the form submission.

eg:

  1. user submits form, enqueue background job
  2. redirect to the show action, but add sleep(1) after rendering to simulate slow network
  3. during the sleep, the background job completes and calls broadcast_replace_to blog_guid
  4. the user receives the response from (2), and is just stuck at the “loading…” state forever.

I might add a fallback so that the “loading…” page does an old-fashioned refresh after 5 seconds if it’s still not received a message from turbostreams, but at that point it kind of seems like maybe I should just rely entirely on polling and delete all the turbostreams code.