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.
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:
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:
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:
- Isolate SDP blobs from candidates.
- Deliver SDP immediately.
- 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 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.