Rails’ ActionCable is excellent for building realtime features in applications. However, in some realtime communication applications, ActionCable is just not good enough. Especially in a stack where order matters.

In building realtime communication apps, other paradigms reign supreme. I’m talking about an event-driven paradigm like what Node.js and the JavaScript ecosystem give us.

With WebRTC, orderliness guarantees reliable communication.

Here’s an example. In the SDP exchanges created before establishing a peer-to-peer connection, the offer always goes first, then an answer. Without an offer, there won’t be an answer. Everything is event-driven: events trigger subsequent events needed to establish the connection.

"Offer/answer SDP exchange between peers"

High Performance Browser Networking - Ilya Grigorik

In between sending an offer and receiving an answer, some other events occur. The most important is the generation of candidate pairs. On the frontend, with the help of Stimulus, one could have something like this:

  subscription() {
    received(data) {
      // ...
      switch (data.type) {
        case _this.JOIN_ROOM:
          return _this.joinRoom(data)
        case _this.EXCHANGE:
          if (data.to !== _this.currentUser) return
          return _this.exchange(data)
        default:
          return
      }
    }
    // ...
  }

ActionCable Delivers Messages Out Of Order

There’d be nothing wrong with this approach if only ActionCable delivered messages in order. The Rails team didn’t build ActionCable with ordered message delivery in mind, and this is where the problem starts. Your video conferencing app will be flaky if you don’t take care of how you deliver messages.

I’ve been through this before. To confirm ActionCable didn’t deliver messages in order, I wrote some code in the controller:

  (1..10).each do |int|
    ActionCable.server.broadcast "room_channel_#{params[:id]}", int
  end

The messages, as shown in the browser console, were delivered out of order:

ActionCable out of order message delivery

The random delivery of messages ensured that the few repositories I found on GitHub with similar implementation for signalling behaved unpredictably by delivering media tracks sometimes. You want media tracks to be delivered at all times for your software to be deemed usable.

With this, you’d get SDP and candidate pairs mixed up as shown below:

Out of order exhcanges

What we can see here is exchanges spread haphazardly. We can see that first, an offer comes in, followed by a plethora of candidate pairs, then an answer at the bottom. An order like this is not good because the ICE agents on both ends need to know each other’s intentions and agree to talk to each other before exchanging candidate pairs.

With the way things stand now, by the time an ICE agent gets an answer, the good candidate pairs would have passed already, and the agent would miss them; this will result in a failed peer-to-peer connection, meaning the agents won’t exchange any media tracks.

Separating Messages With A Background Job

To add some reliability to the flow of exchanges, you should separate SDP from candidates with a background job. The solution to this problem plays out as follows:

  1. Isolate SDP blobs from candidates.
  2. Deliver SDP immediately.
  3. Send candidate pairs through a background job with some delay.
  def receive(data)
    if data.key?('sdp') || data.value?('JOIN_ROOM')
      ActionCable.server.broadcast("room_channel_#{params[:room_id]}", data)
    else
      CandidateWorker.perform_async("room_channel_#{params[:room_id]}", data)
    end
  end

The background job, with some delay, broadcasts the messages passed to it as usual.

  class CandidateWorker
    include Sidekiq::Worker
    sidekiq_options retry: 3

    def perform(channel, data)
      sleep 0.2
      ActionCable.server.broadcast(channel, data)
    end
  end

I implemented the delay here to enable the SDP answer and offer to go first before the candidates. Even though Sidekiq has a feature to delay jobs, its timing isn’t precise enough for this purpose, and that’s why I used the good ol’ sleep to implement the delay.

The above guarantees that answers and offers are exchanged between ICE agents first, allowing for a stable connection every time.

In order exchange

In a Node.js environment where things are event-driven, this problem is inexistent. Events get to the event loop and are picked from there, so you’re guaranteed order, and things work out as expected.

Last Update: January 09, 2024