Note: as of March 2021 both experiments no longer work in Chrome.
QUIC-based DataChannels are being considered as an alternative to the current SCTP-based transport. The WebRTC folks at Google are experimenting with it:
Looking for feedback: QUIC based RTCQuicTransport and RTCIceTransport API's are available as origin trial in Chrome 73 for experimentation.https://t.co/KVVEVmggms
— WebRTC project (@webrtc) February 1, 2019
Let’s test this out. We’ll do a simple single-page example similar to the WebRTC datachannel sample that transfers text. It offers a complete working example without involving signaling servers and also allows comparing the approach to WebRTC DataChannels more easily.
Before we jump into the code, first let’s recap some basics of the DataChannel.
Super-quick DataChannel recap
DataChannels in WebRTC allow the exchange of arbitrary data between peers. They can be either reliable which is very useful for things like file transfers or unreliable, which can for example be used to exchange position information in a game. The API is an extension of WebRTCs RTCPeerConnection and looks like this:
1 2 3 4 5 6 7 8 9 10 11 |
const dc = pc.createDataChannel("some label string"); // wait for this to be open, e.g. by adding an event listener, then call send dc.send("some string"); // on the other side otherPc.addEventListener('datachannel', e => { const channel = e.channel; channel.onmessage = event => { console.log('received', event.data); }); }); |
The WebRTC samples page has some examples for sending simple strings as well as binary data like arraybuffers.
The DataChannel uses a protocol called SCTP. This runs in parallel with the RTP based transport used for voice and video streams. Unlike UDP which is usually used voice and video streams, SCTP provides all kinds of features like multiplexing many channels over the same connection as well as providing reliable, partially reliable (i.e. reliable but unordered) and unreliable modes.
Google introduced QUIC in 2012. Much like what it did with WebRTC, it later took QUIC to the IETF and is now HTTP/3. QUIC provides a number of nifty features including reduced latency, bandwidth estimation based congestion control, forward error correction (FEC), and implementation in the user space vs. the kernel for faster deployment cycles.
For WebRTC, the QUIC protocol might provide an alternative to SCTP as a transport for DataChannel. Also the current experiment is trying to avoid using the RTCPeerConnection API (and SDP!), using a standalone version of the ICE transport. Think of that as a virtual connection that adds a bit of security and a hole bunch of NAT traversal.
The below WebRTC Boston video of Ian Swett of the Chrome networking team covering this topic is a couple of years old, but gives some additional background:
First steps with QUIC
Fortunately, much of code from the First steps with ORTC blog post back from 2015 remains relevant and and adapts quickly for this new API. Let’s dive right into it.
Clone the code here or try it out here. Note that Chrome (73+ which is currently Canary) needs to be started with special flags to enable the experiment locally:
1 |
google-chrome-unstable --enable-blink-features=RTCQuicTransport,RTCIceTransportExtension |
Setting up an ICE transport
The RTCIceTransport specification is modeled after ORTC, so setting up the ICE transport is quite similar to the old code we have:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
const ice1 = new RTCIceTransport(); ice1.onstatechange = function() { console.log('ICE transport 1 state change', ice1.state); }; const ice2 = new RTCIceTransport(); ice2.onstatechange = function() { console.log('ICE transport 2 state change', ice2.state); }; // Exchange ICE candidates. ice1.onicecandidate = function(evt) { console.log('1 -> 2', evt.candidate); if (evt.candidate) { ice2.addRemoteCandidate(evt.candidate); } }; ice2.onicecandidate = function(evt) { console.log('2 -> 1', evt.candidate); if (evt.candidate) { ice1.addRemoteCandidate(evt.candidate); } }; // Start the ICE transports. ice1.start(ice2.getLocalParameters(), 'controlling'); ice2.start(ice1.getLocalParameters(), 'controlled'); ice1.gather(iceOptions); ice2.gather(iceOptions); |
Unlike ORTC, this API does not have an RTCIceGatherer though. This is already enough to establish the ICE transport.
Setting up a QUIC transport
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
const quic1 = new RTCQuicTransport(ice1); quic1.onstatechange = function() { console.log('QUIC transport 1 state change', quic1.state); }; const quic2 = new RTCQuicTransport(ice2); quic2.onstatechange = function() { console.log('QUIC transport 2 state change', quic2.state); }; // Add an event listener for the QUIC stream. quic2.addEventListener('quicstream', (e) => { console.log('QUIC transport 2 got a stream', e.stream); receiveStream = e.stream; }); |
In this point, the experiment deviates from the specification which uses certificate fingerprints. Instead, a pre-shared key is used as pointed out in a note in the original blog post:
Note: The RTCQuicTransport connection is setup with a pre shared key API. We do not currently plan on keeping this API past the origin trial. It will be replaced by signaling remote certificate fingerprints to validate self-signed certificates used in the handshake, once this support has been added to QUIC in Chromium.
So far so good.
Using the QUICStream to send and receive data
Using a QUICStream is a tiny bit more complicated than using a WebRTC DataChannel. The WHATWG streams API (read more about it on MDN) was considered but is not implemented.
We create the sendStream when the QUIC transport becomes connected as it will error before then:
1 2 3 4 5 6 7 8 |
quic1.onstatechange = function() { console.log('QUIC transport 1 state change', quic1.state); if (quic1.state === 'connected' && !sendStream) { sendStream = quic1.createStream('webrtchacks'); // similar to createDataChannel. document.getElementById('sendButton').disabled = false; document.getElementById('dataChannelSend').disabled = false; } }; |
and enable the send button and the input text area. Upon clicking the send button, the text is grabbed from the text area, encoded as a Uint8Array and written into the stream:
1 2 3 4 5 6 7 8 9 |
document.getElementById('sendButton').onclick = () => { const rawData = document.getElementById('dataChannelSend').value; document.getElementById('dataChannelSend').value = ''; // we need a Uint8Array. Fortunately text is easy to convert using TextEncoder. const data = encoder.encode(rawData); sendStream.write({ data, }); }; |
The first write will trigger the onquicstream event on the remote QUICTransport:
1 2 3 4 5 6 7 |
// Add an event listener for the QUIC stream. quic2.addEventListener('quicstream', (e) => { console.log('QUIC transport 2 got a stream', e.stream); receiveStream = e.stream; receiveStream.waitForReadable(1) .then(ondata); }); |
and we will wait for data to become available for reading:
1 2 3 4 5 6 7 8 |
function ondata() { const buffer = new Uint8Array(receiveStream.readBufferedAmount); const res = receiveStream.readInto(buffer); const data = decoder.decode(buffer); document.getElementById('dataChannelReceive').value = data; receiveStream.waitForReadable(1) .then(ondata); } |
This will read all available data from the receiveStream, decode it as text, updating the output text area. After that, it will again wait for more data to become readable.
Summary & Commentary
Hopefully this example is easier to understand and modify than the one given in the original Google blog post. Client-to-client connections are hardly going to be the primary use-case here – this is well covered by the SCTP-based DataChannels already. However, this might become an interesting alternative to WebSockets with a QUIC-based server on the other end. Before this can happen, a good way to represent unreliable and unordered channels needs to be defined. The suggestions in the blog post seem very much like hacks in my opinion.
Beyond that is it rather unclear to me what external feedback the team is looking for. “Implement the specification instead of again taking shortcuts which are going to stick around for years” is rather obvious. Also the community group consensus now seems to be to use WHATWG streams which makes asking developers to test a homegrown API for dealing with reading even weirder.
I also wish Chromium’s SCTP had some additional features. For example, this DataChannel request the highest starred Chromium native issue remains virtually untouched for three years. I do not quite understand why the focus on QUIC when there is work to do on SCTP, but that shouldn’t stop anyone from testing QUIC and giving feedback.
Do you have feedback on the API? Since its a bit unclear how to officially submit it, leave a comment here and at least we will read it!
{“author”: “Philipp Hancke“}
Denis says
I’m got “Uncaught ReferenceError: RTCIceTransport is not defined
at quic.html:64” error on Chrome 74.0.3703.3 macOS Mojave. I’ve enabled “Experimental QUIC protocol” into Chrome’s flags but could not find the RTCIceTransportExtension flag.
Philipp Hancke says
looks like you can’t enable that from chrome://flags so you’ll have to resort to the command line parameters.
Lennart Grahl says
I find it a tad absurd that Google, the big player, is focusing more on proprietary extensions while the SCTP implementation being used, namely usrsctp, is (to my knowledge) not provided any resources whatsoever. These could be invested in finally implementing the standard that WebRTC requires (e.g. ndata) and improve the library for the WebRTC use case in general which in turn allows to fix long-standing issues in Google’s implementation.
Iñaki Baz Castillo says
Just to confirm, since QUIC is not message boundary but stream based, the following code does not guarantee that the received and decoded
data
matches what the sender wrote insendStream.write({ data })
, right?js
const buffer = new Uint8Array(receiveStream.readBufferedAmount);
const res = receiveStream.readInto(buffer);
const data = decoder.decode(buffer);
I mean, if sent data was “1234” the receiver may get “123”, is this correct? And assuming it’s that way, the application should provide a “message format” (if needed).
Iñaki Baz Castillo says
Oh, autoreply:
https://wicg.github.io/web-transport/#dom-streamwriteparameters
> finished, of type boolean.
>
>Set to true if this is the last data to be written. For QUIC, this will result in a STREAM frame with the FIN bit set.
Philipp Hancke says
that would be like opening/closing a datachannel for each message?
Iñaki Baz Castillo says
Yes. It seems that in QUIC streams are “cheap”.