<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Gary Passero</title>
    <description>The latest articles on DEV Community by Gary Passero (@gpassero).</description>
    <link>https://dev.to/gpassero</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3835539%2F96c4d61f-d365-4cf1-9d4d-f85372e734bb.jpg</url>
      <title>DEV Community: Gary Passero</title>
      <link>https://dev.to/gpassero</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/gpassero"/>
    <language>en</language>
    <item>
      <title>Fixing a stubborn class of flaky Turbo Stream system tests</title>
      <dc:creator>Gary Passero</dc:creator>
      <pubDate>Fri, 20 Mar 2026 14:20:06 +0000</pubDate>
      <link>https://dev.to/gpassero/fixing-a-stubborn-class-of-flaky-turbo-stream-system-tests-1lpa</link>
      <guid>https://dev.to/gpassero/fixing-a-stubborn-class-of-flaky-turbo-stream-system-tests-1lpa</guid>
      <description>&lt;p&gt;If you've written system tests against a Rails app that uses Turbo Streams, you've probably hit this: the test clicks a button, a background job runs, and a progress bar (or some other element) should update — but the assertion intermittently fails because the DOM hasn't caught up yet.&lt;/p&gt;

&lt;p&gt;The usual advice is to use &lt;code&gt;Turbo::SystemTestHelper#connect_turbo_cable_stream_sources&lt;/code&gt;, which waits for the WebSocket subscription to be established before proceeding. That fixes the most common race condition: a broadcast firing before the browser has connected to the cable stream.&lt;/p&gt;

&lt;p&gt;But there's a second, less-discussed race condition that connect_turbo_cable_stream_sources doesn't address.&lt;/p&gt;

&lt;h2&gt;
  
  
  The real problem
&lt;/h2&gt;

&lt;p&gt;Consider a job that broadcasts twice in sequence — once at 0% and once at 100% when it finishes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;  &lt;span class="c1"&gt;# in your job&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;broadcast_update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="no"&gt;ProgressBar&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;percent_complete&lt;/span&gt;&lt;span class="p"&gt;:,&lt;/span&gt; &lt;span class="n"&gt;result_message&lt;/span&gt;&lt;span class="p"&gt;:).&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;stream_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And the test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;  &lt;span class="n"&gt;click_on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'Update'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;have_css&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data_test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'progress-bar'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
  &lt;span class="n"&gt;perform_enqueued_jobs&lt;/span&gt;
  &lt;span class="n"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;to&lt;/span&gt; &lt;span class="n"&gt;have_content&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'100%'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# flaky&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The WebSocket connection is fine. The job completes. But &lt;code&gt;expect(page).to have_content('100%')&lt;/code&gt; still fails intermittently. &lt;/p&gt;

&lt;p&gt;You can observe:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;perform_enqueued_jobs is called&lt;/li&gt;
&lt;li&gt;Job runs, calls broadcast_replace_to (0%)  → queued on thread pool&lt;/li&gt;
&lt;li&gt;Job runs, calls broadcast_replace_to (100%) → queued on thread pool&lt;/li&gt;
&lt;li&gt;perform_enqueued_jobs returns&lt;/li&gt;
&lt;li&gt;Thread pool processes the 100% update.&lt;/li&gt;
&lt;li&gt;Thread pool processes the 0% update.&lt;/li&gt;
&lt;li&gt;Capybara asserts 100% but the UI shows 0%.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Down the ActionCable rabbit hole
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;broadcast_replace_to&lt;/code&gt; eventually calls &lt;code&gt;Turbo::StreamsChannel.broadcast_replace_to&lt;/code&gt;, which publishes a message through the ActionCable pubsub adapter. When a subscribed client receives that message, it goes through&lt;br&gt;
  &lt;code&gt;ActionCable::Channel::Streams#worker_pool_stream_handler&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;worker_pool_stream_handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;broadcasting&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_handler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;coder: &lt;/span&gt;&lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stream_handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;broadcasting&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_handler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;coder: &lt;/span&gt;&lt;span class="n"&gt;coder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
      &lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;worker_pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;async_invoke&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:call&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;connection: &lt;/span&gt;&lt;span class="n"&gt;connection&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;async_invoke&lt;/code&gt; posts the message handler onto a &lt;code&gt;Concurrent::ThreadPoolExecutor&lt;/code&gt;. That thread pool is genuinely asynchronous — it doesn't block the calling thread, and it doesn't flush before perform_enqueued_jobs returns.&lt;/p&gt;

&lt;p&gt;But there's another source of non-determinism in these parts: &lt;code&gt;AsyncSubscriberMap#invoke_callback&lt;/code&gt; posts each subscriber callback onto the &lt;code&gt;StreamEventLoop&lt;/code&gt;'s thread pool:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;ActionCable&lt;/span&gt;
  &lt;span class="k"&gt;module&lt;/span&gt; &lt;span class="nn"&gt;SubscriptionAdapter&lt;/span&gt;
    &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Async&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;Inline&lt;/span&gt;
      &lt;span class="kp"&gt;private&lt;/span&gt;
        &lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;AsyncSubscriberMap&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="no"&gt;SubscriberMap&lt;/span&gt;
          &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;invoke_callback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="vi"&gt;@event_loop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;super&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
          &lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The fix
&lt;/h2&gt;

&lt;p&gt;A few small targeted patches in a spec support file makes ActionCable stream delivery synchronous in the test environment. &lt;/p&gt;

&lt;p&gt;The first skips the call to &lt;code&gt;post&lt;/code&gt; just shown:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;&lt;span class="no"&gt;ActionCable&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;SubscriptionAdapter&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Async&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;AsyncSubscriberMap&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;module_eval&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
  &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;invoke_callback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;super&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second redefines &lt;code&gt;worker_pool_stream_handler&lt;/code&gt; to call the synchronous &lt;code&gt;invoke&lt;/code&gt; instead of &lt;code&gt;async_invoke&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight ruby"&gt;&lt;code&gt;  &lt;span class="no"&gt;ActionCable&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Channel&lt;/span&gt;&lt;span class="o"&gt;::&lt;/span&gt;&lt;span class="no"&gt;Streams&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;module_eval&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt;
    &lt;span class="kp"&gt;private&lt;/span&gt;

    &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;worker_pool_stream_handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;broadcasting&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_handler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;coder: &lt;/span&gt;&lt;span class="kp"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="n"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stream_handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;broadcasting&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_handler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;coder: &lt;/span&gt;&lt;span class="n"&gt;coder&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

      &lt;span class="nb"&gt;lambda&lt;/span&gt; &lt;span class="k"&gt;do&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="o"&gt;|&lt;/span&gt;
        &lt;span class="n"&gt;connection&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;worker_pool&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;invoke&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;:call&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="ss"&gt;connection: &lt;/span&gt;&lt;span class="n"&gt;connection&lt;/span&gt;
      &lt;span class="k"&gt;end&lt;/span&gt;
    &lt;span class="k"&gt;end&lt;/span&gt;
  &lt;span class="k"&gt;end&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The drop-in file (place it in spec/support/) is here: [&lt;a href="https://gist.github.com/gap777/6cc10654606e8ed27c7d1105b9d9eca5" rel="noopener noreferrer"&gt;https://gist.github.com/gap777/6cc10654606e8ed27c7d1105b9d9eca5&lt;/a&gt;]&lt;/p&gt;

&lt;p&gt;Because spec/rails_helper.rb auto-requires everything under spec/support/, no other changes are needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this works
&lt;/h2&gt;

&lt;p&gt;With the patch in place, &lt;code&gt;perform_enqueued_jobs&lt;/code&gt; now fully drains the call chain: &lt;/p&gt;

&lt;p&gt;job runs → &lt;br&gt;
  broadcast_replace_to → &lt;br&gt;
    pubsub delivers → &lt;br&gt;
      worker_pool_stream_handler fires → &lt;br&gt;
        invoke runs the handler synchronously on the same thread. &lt;/p&gt;

&lt;p&gt;By the time &lt;code&gt;perform_enqueued_jobs&lt;/code&gt; returns, the DOM update has already been dispatched, and Capybara's assertion sees the final state every time.&lt;/p&gt;

</description>
      <category>rails</category>
      <category>turbo</category>
      <category>testing</category>
      <category>actioncable</category>
    </item>
  </channel>
</rss>
