Elixir

Data channels in Elixir WebRTC

Michał ŚledźSep 24, 20248 min read

After we launched the first version of Elixir WebRTC, the thing people asked about most often was data channels. Back then, our prime focus was on multimedia development, so data channels were not on the priority list. However, Elixir WebRTC is at its more mature stage now, which means we could shift our roadmap a little bit!

Before we start, I would like to thank Łukasz Wala as data channels are his work! :)

What are data channels?

For those who don’t know, the idea behind WebRTC’s data channels is very simple — they allow you to send arbitrary data using the same P2P connection that is used to send media (it uses SCTP over DTLS over ICE — wow, that’s a mouthful).

Data channels can be used for a variety of things, from text chats that accompany media data, to use cases completely unrelated to media, where low latency is important, like online video games.

You can read more in this blog post.

JavaScript API

Let’s start with how you can use data channels in JavaScript, or, in other words, on the browser side. To create a new data channel, we can use createDataChannel function, which takes the channel’s label and some options as its arguments. In our case, we will use the default options:

const pc = new RTCPeerConnection();
const channel = pc.createDataChannel("my channel label")

The other peer will be notified of that by two events:

  • datachannel informing you there is a new data channel,
  • open informing you that a new data channel is ready for sending and receiving messages.
otherPc.ondatachannel = (ev) => {
  // ev.channel.label will be the same as on the other side  
  console.log(`New channel, label: ${ev.channel.label}`);
  ev.channel.onopen = () => {
    console.log(`channel ${ev.channel.label} ready for writing`);
  }
}

Once the data channel is open, we can use send function and onmessage callback to send and receive data:

channel.onmessage = event => console.log("New message", msg.data);
channel.send("my message");

A couple of remarks:

  • Adding the very first channel requires performing renegotiation. You can read more about it in our docs or on mdn. For all subsequent channels, renegotiation is not needed.
  • datachannel event requires an established ICE connection. This means you need to exchange your ICE candidates to be informed about a new data channel.

Here is a complete example in jsfiddle illustrating which callbacks should be invoked and in what order.

Elixir API

From version 0.5.0, you can use data channels in Elixir! Let’s see how the JavaScript API maps to Elixir’s one.

First of all, in Elixir WebRTC, instead of callbacks we rely on messages:

  • ondatachannel maps to the {:data_channel, data_channel}
  • onopen maps to the {:data_channel_state_change, channel_ref, :open}

Creating a new data channel in Elixir will look like this:

{:ok, pc} = PeerConnection.start_link()
{:ok, %DataChannel{ref: ref} = dc} = PeerConnection.create_data_channel(pc, "my channel label")

receive do
  {:ex_webrtc, ^pc, {:data_channel_state_change, ^ref, :open}} ->
    IO.puts("channel #{dc.label} ready for writing")
end 

And receiving information about the new data channel on the other side will look like this:

receive do
  {:ex_webrtc, ^other_pc, {:data_channel, %DataChannel{} = dc}} ->
    IO.puts("New channel, label: #{dc.label}")
  {:ex_webrtc, ^other_pc, {:data_chanel_state_change, _ref, :open}} ->
    IO.puts("Channel ready for writing")
end

Once the data channel is open, we can use PeerConnection.send_data/3 function to send data. Incoming messages will be delivered, again, as Erlang messages:

PeerConnection.send_data(pc, dc.ref, "hello world")

receive do
  {:ex_webrtc, ^pc, {:data, _ref, msg}} -> 
    IO.puts("New message #{msg}")
end

The full list of messages emitted by peer connections is available here.

Also, check out a complete chat application implemented using data channels in Elixir WebRTC.

Label is not unique

It’s important to note that label might not be unique. If you wish to use it for identification purposes, you have to take care of guaranteeing its uniqueness. A really good description of what label is can be found on mdn.

A unique identifier of the data channel is id. It’s an integer between 0 and 65,535. However, bear in mind that it might initially be set to null/nil if you add your data channel before the first negotiation.

This is why in Elixir, we have the third (yeah!) identifier called ref. Imagine you add two data channels before the first negotiation. Once we get a notification that a data channel is open, we need to correlate this message with the correct data channel struct:

pc = PeerConnection.start_link()
# labels might be the same, id is initially nil
# as we are before the first negotiation
%DataChannel{ref: ref1, id: nil} = PeerConnection.create_data_channel("ch1");
%DataChannel{ref: ref2, id: nil} = PeerConnection.create_data_channel("ch1");

# negotiate the connection
# ...

receive do
  {:ex_webrtc, ^pc1, {:data_channel_state_change, ^ref1, :open} ->
    # id should be negotiated now, we need
    # to fetch the fresh DataChannel struct
    dc1 = PeerConnection.get_data_channel(pc, ref1)   
    IO.puts("Data channel id: #{dc1.id}, label: #{dc1.label}, ref: #{inspect(dc1.ref)} is ready for writing")
  {:ex_webrtc, ^pc2, {:data_channel_state_change, ^ref2, :open} ->
    dc2 = PeerConnection.get_data_channel(pc, ref2)
    IO.puts("Data channel id: #{dc2.id}, label: #{dc2.label}, ref: #{inspect(dc2.ref)} is ready for writing")    
end 

Reliable or unreliable, ordered or unordered?

One of the coolest features of the SCTP protocol is that we can choose whether our transmission should be reliable or not as well as whether it should be ordered or not. Because data channels are encapsulated into SCTP, we have access to this feature out-of-the-box!

The default settings are: ordered: true, maxRetransmits: null, which implies reliable and ordered data transmission — akin to TCP but message-based instead of stream-based. These settings can be changed when creating a new data channel, e.g.:

pc = new RTCPeerConnection()
pc.createDataChannel('my label', { ordered: false, maxRetransmits: 10})

The above code will result in unordered, unreliable data transmission. Here are a couple of configurations and their meanings:

  • { ordered: true, maxRetransmits: null } — reliable, ordered transmission
  • { ordered: true, maxRetransmits: 10 } — unreliable, ordered transmission. The sender will attempt to retransmit the packet up to 10 times.
  • { ordered: false, maxRetransmits: null } — reliable, unordered transmission.
  • { ordered: false, maxRetransmits: 10 } — unreliable, unordered transmission. The sender will attempt to retransmit the packet up to 10 times.
  • { ordered: false, maxRetransmits: 0 } — unreliable, unordered transmission. There won’t be any retransmissions of any packet.

Elixir WebRTC fully supports both properties. The above JavaScript code maps to the following Elixir code:

pc = PeerConnection.start_link()
PeerConnection.create_data_channel("my label", ordered: false, max_retransmits: 10)

When would you prefer to use unreliable or unordered data transmission? In some scenarios, e.g. in online gaming, a small latency is more important than a reliable transmission. Imagine you lost a packet indicating player action (like movement) or game state update (like other players’ positions). Retransmitting these packets doesn’t make much sense as the player has probably already provided many more new actions (they clicked the forward button 15 more times) or the game state updated a few more times. Hence, it’s better to send current player action or current players’ positions instead of retransmitting old and obsolete information. That’s also why you are sometimes teleported over the map :)

Protocol

Data channels allow you to set an app-defined subprotocol used for exchanging data on the channel. For example, you can indicate that on one channel there will be JSONs, on another channel there will be raw binary data, and on yet another channel there will be simple text data.

The protocol, like other data channel properties, can be set during creation time. A good example can be found on mdn:

// copied from mdn
const pc = new RTCPeerConnection();
const dc = pc.createDataChannel("my channel", {
  protocol: "json",
});

function handleChannelMessage(dataChannel, msg) {
  switch (dataChannel.protocol) {
    case "json":
      /* process JSON data */
      break;
    case "raw":
      /* process raw binary data */
      break;
  }
}

As always, the counterpart in Elixir:

pc = PeerConnection.start_link()
PeerConnection.create_data_channel("my label", protocol: "json")

# then, on the other side
receive do
  {:ex_webrtc, ^other_pc, {:data_channel, %DataChannel{} = dc}} ->
    IO.puts("New channel, label: #{dc.label}, protocol: #{dc.protocol}")
end

What is yet to be done

There’s still some stuff that may be added or improved. Firstly, the data channel offers two types of negotiation:

  • in-bound — here one side calls createDataChannel and other receives the datachannel event with the channel object,
  • out-of-bound — both sides call createDataChannel using the same value as id.

Currently, we only support in-bound negotiation, as it’s the default option, and arguably, the easier one to use. The only downside is that it doesn’t allow you to configure data channel reliability parameters independently on both sides.

There are also some minor differences in how the readyState property behaves in Elixir WebRTC compared to the standard, as of now we skip the closing state completely (when you close the data channel, it goes from open directly to closed).

And lastly, we use Rust implementation of SCTP, and this is the reason why data channels are an optional feature. We don’t want to force people to install anything but Elixir to use Elixir WebRTC, but implementing a sans I/O version of SCTP would take a loooong time. If you want to use data channels in Elixir WebRTC now, you need to add ex_sctp to dependencies explicitly:

def deps do
  [
    {:ex_webrtc, "~> 0.5.0"},
    {:ex_sctp, "~> 0.1.0"}
  ]
end

Otherwise, local functions related to data channels will rise, and attempts to negotiate data channels by the remote peer will be ignored with an appropriate log.

Happy streaming!

We’re Software Mansion: multimedia experts, AI explorers, React Native core contributors, community builders, and software development consultants. Hire us: [email protected].