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.
The usual advice is to use Turbo::SystemTestHelper#connect_turbo_cable_stream_sources, 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.
But there's a second, less-discussed race condition that connect_turbo_cable_stream_sources doesn't address.
The real problem
Consider a job that broadcasts twice in sequence — once at 0% and once at 100% when it finishes:
# in your job
def broadcast_update(message = nil)
ProgressBar.new(percent_complete:, result_message:).update(stream_id)
end
And the test:
click_on('Update')
expect(page).to have_css(data_test('progress-bar'))
perform_enqueued_jobs
expect(page).to have_content('100%') # flaky
The WebSocket connection is fine. The job completes. But expect(page).to have_content('100%') still fails intermittently. Why?
Down the ActionCable rabbit hole
broadcast_replace_to eventually calls Turbo::StreamsChannel.broadcast_replace_to, which publishes a message through the ActionCable pubsub adapter. When a subscribed client receives that message, it goes through
ActionCable::Channel::Streams#worker_pool_stream_handler:
def worker_pool_stream_handler(broadcasting, user_handler, coder: nil)
handler = stream_handler(broadcasting, user_handler, coder: coder)
-> message do
connection.worker_pool.async_invoke handler, :call, message, connection: connection
end
end
async_invoke posts the message handler onto a Concurrent::ThreadPoolExecutor. That thread pool is genuinely asynchronous — it doesn't block the calling thread, and it doesn't flush before perform_enqueued_jobs returns.
So the sequence looks like this:
- perform_enqueued_jobs is called
- Job runs, calls broadcast_replace_to (0%) → queued on thread pool
- Job runs, calls broadcast_replace_to (100%) → queued on thread pool
- perform_enqueued_jobs returns
- Thread pool processes the 100% update.
- Thread pool processes the 0% update.
- Capybara asserts 100% but the UI shows 0%.
The fix
A targeted patch in a spec support file makes ActionCable stream delivery synchronous in the test environment. It redefines worker_pool_stream_handler to call the synchronous invoke instead of async_invoke:
ActionCable::Channel::Streams.module_eval do
private
def worker_pool_stream_handler(broadcasting, user_handler, coder: nil)
handler = stream_handler(broadcasting, user_handler, coder: coder)
lambda do |message|
connection.worker_pool.invoke handler, :call, message, connection: connection
end
end
end
The drop-in file (place it in spec/support/) is here: [https://gist.github.com/gap777/6cc10654606e8ed27c7d1105b9d9eca5]
Because spec/rails_helper.rb auto-requires everything under spec/support/, no other changes are needed.
Why this works
With the patch in place, perform_enqueued_jobs now fully drains the call chain:
job runs →
broadcast_replace_to →
pubsub delivers →
worker_pool_stream_handler fires →
invoke runs the handler synchronously on the same thread.
By the time perform_enqueued_jobs returns, the DOM update has already been dispatched, and Capybara's assertion sees the final state every time.
Top comments (0)