edruder.com i write about anything, or nothing

The Turbo + ActionCable Trap: When Your Real-Time Rails Feature Fights Itself

Two phones, same app, same page containing a timer. One shows 4 minutes on the timer. The other shows 9. The server says 4. Refresh the lagging phone and it snaps to 4 instantly–which makes it maddening to debug, because the bug fixes itself the moment you look at it.

We built a shared timer for our weekly poker game—a Rails 8 app using Turbo for form handling and ActionCable for real-time sync across devices. It worked perfectly in development. It worked perfectly in production, too, right up until someone started clicking the “minus 5 minutes” button a few times in quick succession. Then the clients would diverge.

The pattern was consistent in some ways but not others: one device would fall behind by exactly one adjustment–usually the device that was clicked, but sometimes the passive observer lagged. Any interaction on the lagging device would immediately fix it. And it only happened with rapid clicks, never with deliberate single clicks spaced a few seconds apart.

Here’s what made it hard: Turbo and ActionCable were both doing the right thing. The problem was that they were both doing it at the same time, on the same state, and their page lifecycles were silently interfering with each other.

It took two rounds of investigation and fixes across multiple layers to fully resolve–because the first fix addressed a real problem but exposed a deeper one underneath.

The Architecture That Seems Right

The pattern we used is one you’ll find in a lot of Rails apps that combine Turbo with real-time features. It looks like this:

  1. User clicks a button (say, “adjust timer down by 5 minutes”)
  2. Turbo submits the form as a background HTTP request (no page reload)
  3. The server processes the mutation and broadcasts the new state via ActionCable
  4. All connected clients receive the broadcast and update their UI
  5. The server responds with a redirect back to the edit page
  6. Turbo follows the redirect and re-renders the page with fresh server data

This is textbook Rails. Turbo handles forms. ActionCable handles real-time sync. The controller action looks like what you’d expect:

# In the controller's update action
when "adjust_time"
  minutes = params[:minutes].to_i
  if @timer.adjust_time!(minutes)
    redirect_to edit_path
  else
    redirect_to edit_path, alert: "Cannot adjust time"
  end

And inside adjust_time!, the model broadcasts after persisting the change:

def adjust_time!(minutes)
  # ... compute new time, update the record ...
  broadcast_time_adjusted  # ActionCable broadcast to all subscribers
  true
end

Every connected client has a Stimulus controller subscribed to the ActionCable channel. When a broadcast arrives, it updates the display. Clean separation of concerns. Each piece doing its job.

But notice step 5 and step 6 above. After the server broadcasts the new state to all clients via ActionCable, it also redirects the submitting client back to the page–which Turbo follows, replacing the page body with a fresh render from the server.

The clicking device gets the new state twice: once from the ActionCable broadcast, and again from the Turbo redirect and re-render. In normal operation, this is invisible–both paths deliver the same data, and whichever arrives second is effectively a no-op.

This worked flawlessly in development. Two browser windows side by side, rapid clicks, perfect sync every time.

The reason was simple: with a local server, request round-trips complete in single-digit milliseconds. The timing window where anything could go wrong was vanishingly small. It wasn’t until the app was running in production–real devices on real networks with real latency–that the window opened wide enough for the race to happen.

What Went Wrong

In production, the bug showed up like this: a user would click the adjustment button three times in quick succession. One device would end up showing the correct value–reflecting all three adjustments. The other would show a value that was off by exactly one adjustment, as if it had only received two of the three broadcasts.

A few things made this hard to pin down:

It was intermittent. Slow, deliberate clicks never triggered it. You had to click fast enough that requests overlapped or came in within a few hundred milliseconds of each other.

Either device could be the one that lagged. Usually it was the device that was clicked, but sometimes the passive observer fell behind instead. There was no consistent “active client is right, passive client is wrong” pattern–which made it harder to form a hypothesis.

It self-healed. Any action on the lagging device–clicking a button, navigating away and back, even just refreshing–would snap it to the correct value. The server always had the right state. The problem was purely in how that state reached (or failed to reach) the client.

The server was correct. Every adjustment was processed and persisted. The database always reflected all three clicks. This wasn’t a lost-update problem on the server side–it was a delivery or acceptance problem on the client side.

If you’ve built real-time features with Turbo and ActionCable, you might recognize this pattern. My first instinct was to suspect ActionCable itself–maybe a broadcast was dropped, maybe Solid Cable’s polling missed something. I went down that road. It wasn’t the answer.

The First Fix (That Wasn’t Enough)

The dual update path I described above–ActionCable broadcast plus Turbo redirect/re-render–was the obvious place to start. If the two paths could interfere with each other, maybe eliminating one would fix the problem.

Looking at the controller action, every successful adjustment ended with redirect_to edit_path. That meant Turbo was following the redirect, fetching a fresh page, and replacing the entire page body. During that replacement, the Stimulus controller that managed the timer display would disconnect (tearing down its ActionCable subscription) and a new one would connect (creating a fresh subscription).

There was a window–brief, but real–where the client had no ActionCable subscription at all.

If a broadcast arrived during that window, it would be lost. The client would never receive it. Meanwhile, the Turbo re-render would show whatever the server’s state was at that moment–giving the clicking device a second chance to get the right answer even if it missed a broadcast.

This could explain why sometimes the passive device lagged (it missed a broadcast and had no re-render fallback), and it could also explain why sometimes the clicking device lagged (the re-render showed an intermediate state while a later broadcast was lost during the subscription gap).

This seemed like a plausible explanation. The fix was straightforward: replace redirect_to edit_path with head :no_content for adjustment actions. Since ActionCable was already broadcasting the new state to all clients, the redirect was redundant–its only purpose was to re-render the page, which the broadcast already handled.

when "adjust_time"
  minutes = params[:minutes].to_i
  if @timer.adjust_time!(minutes)
    head :no_content  # was: redirect_to edit_path
  else
    redirect_to edit_path, alert: "Cannot adjust time"
  end

I deployed the fix and tested it. Still broken!

The redirect was a real problem–eliminating it removed the Stimulus disconnect/reconnect churn and the broadcast-loss window that came with it. But it wasn’t the whole story.

With the redirect gone, both clients were now relying entirely on ActionCable for updates. No more asymmetric recovery path. And yet one client still fell behind by exactly one adjustment during rapid clicks. Something else was going on.

The Real Problem: Turbo’s Hidden Page Lifecycle

With the redirect eliminated, I went deeper. If both clients were now getting updates exclusively through ActionCable, and the server was sending all three broadcasts, where was one getting lost?

Dead end: client-side validation logic. The Stimulus controller had staleness checks and time-direction validation on incoming broadcasts. I traced every code path with concrete values for three sequential time_adjusted broadcasts. Every one passed every guard. The JavaScript was not rejecting valid broadcasts.

Dead end: Solid Cable delivery. I traced the full broadcast pipeline end-to-end–from ActionCable.server.broadcast through Solid Cable’s SQLite message table, the polling listener thread, the event loop thread pool, ActionCable’s worker pool, and finally the WebSocket write. There is no mechanism in that pipeline for silent message loss. If a message is inserted into the cable database, it will be delivered to all subscribers. Solid Cable was not the problem.

That left Turbo. Even with head :no_content instead of a redirect, Turbo still manages the form submission lifecycle–and its behavior during rapid submissions is where the problem lives.

Here’s what Turbo’s Navigator does when a form is submitted:

submitForm(form, submitter) {
    this.stop();
    this.formSubmission = new FormSubmission(this, form, submitter, true);
    this.formSubmission.start();
}

That this.stop() call is the key. If a previous form submission is still in-flight when a new one starts, Turbo aborts it. The previous fetch request is cancelled via its AbortController.

This is sensible behavior for navigation–you don’t want two competing page loads. But for rapid-fire mutations that broadcast via ActionCable, the consequences are subtle.

Turbo also disables the submit button when a form submission begins, and re-enables it when the response arrives. For a fast local server returning 204 No Content in a few milliseconds, the button is disabled for an imperceptibly short time–rapid clicks all get through.

But for a production server with real network latency, the timing changes. Clicks might land while the button is disabled (and be silently swallowed), or they might land in a narrow window where an aborted submission’s cleanup asynchronously re-enables the button just after the new submission disabled it.

The result: depending on network conditions, some combination of three rapid clicks produces only two server requests, or produces three server requests where one is aborted client-side. The server processes what it receives and broadcasts for each one. But the number of clicks, the number of server-side mutations, and the number of broadcasts received by each client can all differ–by exactly one–depending on timing.

The Fix

The solution follows directly from the diagnosis: if Turbo’s form submission lifecycle is interfering with ActionCable-driven updates, take Turbo out of the equation for these specific actions.

Step 1: Bypass Turbo for broadcast-driven actions.

Add data-turbo="false" to any form that triggers a server-side mutation which is broadcast to clients via ActionCable:

<%= button_to timer_path(@timer),
      method: :patch,
      params: { action_type: "adjust_time", minutes: -5 },
      data: { turbo: false } do %>
  <span>-5 min</span>
<% end %>

With data-turbo="false", the form submits as a standard browser request–no Navigator, no this.stop(), no AbortController, no button disable/enable dance. Each click fires an independent HTTP request. The browser ignores the 204 No Content response (no navigation, no page replacement), and the UI update comes entirely through ActionCable. Every client gets its update through the same path.

Step 2: Return head :no_content instead of redirecting.

This was my first fix, and it’s still necessary. Without Turbo managing the form, a redirect would cause a full page reload (standard browser behavior for a non-Turbo form getting a 302). Returning 204 No Content tells the browser there’s nothing to do with the response–the ActionCable broadcast handles the UI update.

when "adjust_time"
  minutes = params[:minutes].to_i
  if @timer.adjust_time!(minutes)
    head :no_content
  else
    redirect_to edit_path, alert: "Cannot adjust time"
  end

Note that the error case still redirects–if the adjustment fails, I want to show the user an alert, and there’s no race condition concern because no broadcast was sent.

What this changes architecturally:

Before the fix, adjustment actions had two update paths: the ActionCable broadcast (for all clients) and the Turbo redirect/re-render (for the clicking client only). These paths raced against each other and against Turbo’s Navigator lifecycle.

After the fix, there is exactly one update path: the ActionCable broadcast. Every client–whether it clicked the button or not–receives the update the same way. No asymmetry, no race, no timing dependence.

What I Took Away From This

It’s possible that there’s a better way to fix this kind of problem. The fix described here does seem to work, and work well. But it’s possible that there’s a better solution–maybe one that doesn’t entail disabling Turbo in certain contexts. If you think of a better solution, I’d love to hear about it!

The specific bug was in a poker timer, but the underlying pattern is general. Any time you have a Rails action that does both of these things:

  1. Broadcasts state via ActionCable (or any WebSocket/real-time channel)
  2. Responds with a Turbo-managed redirect or page update

…you might have two competing update paths for the same state change. In the normal case–one user, one action, low latency–they deliver the same data and you never notice the duplication. But under pressure (rapid actions, multiple clients, production latency), they interfere with each other.

Here’s how I think about the interference now:

Turbo owns the page lifecycle. When Turbo manages a form submission, it controls the fetch request, the response handling, the page replacement, and the Stimulus controller lifecycle. If it decides to abort a request, disable a button, or replace the page body, those decisions cascade through everything else on the page–including your ActionCable subscriptions and Stimulus state.

ActionCable assumes a stable connection. Broadcasts are fire-and-forget from the server’s perspective. If the client’s subscription is torn down (because Turbo is replacing the page) or the client’s JavaScript is busy processing a Turbo lifecycle event, the broadcast has no way to retry or confirm delivery.

The combination creates asymmetry. The device that triggered the action gets special treatment from Turbo (re-render, fresh server state, Navigator lifecycle management). Every other device gets only the ActionCable broadcast. When Turbo’s lifecycle interferes with broadcast delivery or acceptance, the devices diverge–and they diverge in ways that depend on network timing, making the bug intermittent and hard to reproduce in development.

The fix isn’t “use ActionCable better” or “use Turbo better.” It’s recognizing that for actions whose results are broadcast to all clients in real time, Turbo should not be managing the form submission at all. Let the browser handle the HTTP request. Let ActionCable handle the state update. One path, one source of truth, no interference.

Signs You Might Have This Bug

If your app matches several of these, you’re probably vulnerable to this class of problem:

  • A Turbo-submitted form triggers an ActionCable broadcast. This is the core pattern. The form response and the broadcast are two delivery paths for the same state change.

  • Multiple clients observe the same state. If only the submitting user cares about the result, the dual update paths are harmless–there’s no other client to diverge from. The bug requires at least two clients.

  • Users perform rapid or repeated actions. Single actions with seconds between them are unlikely to trigger the race. The timing window opens when actions overlap or arrive within a few hundred milliseconds of each other.

  • Your controller redirects after a successful mutation. The redirect-and-rerender cycle is what causes Stimulus disconnect/reconnect and the ActionCable subscription gap. head :no_content is necessary but not sufficient on its own–Turbo’s Navigator still manages the form submission.

  • You’re using Solid Cable or another polling-based ActionCable adapter. Polling introduces delivery latency that widens the race window. The default Solid Cable polling interval of 0.5 seconds means broadcasts can queue for up to half a second before delivery. This isn’t the cause of the bug–the core vulnerability exists regardless of your ActionCable adapter–but polling makes it more likely to manifest. Even with Redis-backed ActionCable, the race window exists; it’s just narrower.

  • Your production environment has meaningful network latency. This is what makes the bug invisible in development. Local round-trips in single-digit milliseconds don’t give the race enough time to trigger.

If you recognize this pattern in your app, the fix is the same regardless of what your feature does: add data-turbo="false" to forms whose successful actions are broadcast via ActionCable, and return head :no_content instead of redirecting.

Coming Up

Bypassing Turbo and eliminating the redirect fixed the primary race condition. But during the investigation, I found several other issues that contributed to the unreliability–a read-then-write race in the mutation method, a no-op WebSocket disconnection handler that silently dropped broadcasts, and a Solid Cable polling interval that was tuned for low DB load rather than real-time responsiveness. I also discovered that fixing the Turbo lifecycle issue inadvertently resolved a separate class of bugs–phantom sound effects triggered by stale Stimulus controller state during page replacement–that I hadn’t fully diagnosed yet.

In the next post, I’ll cover the defense-in-depth fixes that made the real-time feature truly robust: pessimistic locking, WebSocket reconnect recovery, and Solid Cable tuning.