WebRTC DataChannel via FastAPI by example

One of my current side projects is developing a modern low-code platform to build machine vision applications. The backend is based on Python, allowing users to tap into the rich ecosystem of libraries available in the Python ecosystem. The frontend is built with web technologies, enabling users to create applications without installing any software.

A key feature of the platform is its ability to stream input and output data with low latency and high throughput between the backend and frontend. WebRTC, with its peer-to-peer (P2P) functionality, is currently the only technology that supports this type of data transfer in the browser.

Getting started with WebRTC can be quite daunting, as it requires combining several technologies to make it work. In my case:

  • WebRTC (a Python client and the JavaScript API)
  • WebSockets for signaling (also a Python client, a relay server, and the JavaScript API)
  • A web server to serve the frontend

During my initial experiments, I found various examples, but none covered all the technologies I needed. Therefore, I wanted to write this tutorial to help others in the same situation.

The goal of this post is to create a simple WebRTC DataChannel connection between a Python backend and a JavaScript frontend using FastAPI to serve the website and handle signaling via WebSockets.

The following libraries and APIs will be used:

The complete code for this example can be found in this GitHub repository.

Theory

I will quickly explain the basics of how a WebRTC connection is established so that we can understand the code we’ll write later. For a more in-depth explanation, I’ll point to other resources.

The first important thing to understand is that to establish a WebRTC connection between two peers, they need to exchange information about the connection. This process is called signaling1. While the format (SDP) of this signaling exchange is part of the WebRTC protocol, the means of delivery is not and must be implemented via another protocol. A popular choice, which we will also use, is WebSockets.

To enable P2P functionality across a wide range of network configurations, WebRTC needs to traverse NATs and firewalls. This is done through a process called Interactive Connectivity Establishment (ICE). Specifically, a STUN server is used to determine the client’s public IP address and port. If a direct connection cannot be established, a TURN server can be used to relay the data between clients.

The diagram below shows the signaling process between two peers.

Figure 1: WebRTC Signaling without trickle ICE. Adapted from [here](https://hacks.mozilla.org/2013/07/webrtc-and-the-ocean-of-acronyms/).

Figure 1: WebRTC Signaling without trickle ICE. Adapted from here.

The process of gathering ICE candidates is often a bottleneck in establishing a WebRTC connection. Some frameworks and APIs allow sending the ICE candidates after the offer, a process known as trickle ICE. Unfortunately, aiortc does not support trickle ICE yet.2 Therefore, we will focus on the simpler case where we wait for the ICE candidates to be gathered before sending the offer.

Besides discovering the network configuration, the signaling process also includes exchanging information about the media streams and data channels that should be established.

Further Reading:

  • If you want to understand or debug a WebRTC offer, I suggest the Anatomy of a WebRTC SDP.
  • This article can only scratch the surface of WebRTC’s complexity. If you want to learn more, I recommend the WebRTC for the Curious book.

Development

To keep the code simple, we will not implement any error handling or security features.

If you want to follow along, I recommend creating a new directory and a Python virtual environment. Then install the following packages: pip install fastapi websockets aiortc. You can also create separate environments for the server and the clients, but it is not necessary. If you do so, the server doesn’t need the aiortc package and the clients don’t need the fastapi package.

We can split the development into five parts:

  1. The FastAPI WebSocket signaling server
  2. A Python WebRTC client, which accepts incoming WebRTC connections and sends data (producer)
  3. Optional: A second Python WebRTC client as the data consumer, to test our setup before we move to JavaScript
  4. A JavaScript WebRTC client that will offer a WebRTC connection and consume the data
  5. Serving HTML + JavaScript via FastAPI

Let’s start with the server.

1. FastAPI WebSocket signaling server

Let’s first build the signaling server, which will relay the signaling messages between the two peers. We will use FastAPI to build the server and WebSockets to handle the communication.

This code was adapted from the websockets documentation, which I recommend as an excellent resource for learning more about WebSockets in Python.

First, we set up the FastAPI app and create a set to store the connected clients.

1
2
3
4
5
6
from fastapi import FastAPI, WebSocket

# Create a FastAPI app
app = FastAPI()
# Set to store connected clients
connected = set()

The server accepts WebSocket connections at the /ws endpoint. If a WebSocket is connected, it is added to our pool of connected clients. After this the server forwards all incoming messages to all other connected clients. The function will run until the connection is closed, at which point we remove the connection from our pool of connected clients. Due to the async nature of the function, the server can handle multiple connections efficiently at the same time. For more information on how to use WebSockets in FastAPI, I recommend the FastAPI documentation.

 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
    await websocket.accept()
    # Register
    connected.add(websocket)
    try:
        # Forward messages
        while True:
            message = await websocket.receive_text()
            for client in connected:
                if client != websocket:
                    await client.send_text(message)
    finally:
        # Unregister.
        connected.remove(websocket)

With this code, the server is ready to accept connections and forward messages. You can run this by saving the code in a file called app.py and running fastapi run in the directory. But without a client, it will not do much.

2. Python WebRTC client (producer)

Now we will create a Python client, which will represent our data producer. This client will wait for a WebRTC offer from the consumer, create an answer, and send it to the signaling server. After the connection is established, it will send pings every second to the consumer.

Before we start the signaling, we will build our RTCPeerConnection description. We will add an event handler that starts sending pings when the data channel is open.

We wrap this into a function so that we can execute it later when we are inside the asyncio event loop. This is only a technical detail related to the aiortc implementation, as the channel is an AsyncEventEmitter and always uses the current loop (i.e., it doesn’t allow us to specify another loop).

 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
import asyncio
import time

import websockets
from aiortc import RTCPeerConnection

def setup_producer_logic():
    pc = RTCPeerConnection()

    @pc.on("datachannel")
    def on_datachannel(channel):
        print("Data channel created by consumer")

        async def send_pings():
            while True:
                channel.send("ping %d" % time.time())
                await asyncio.sleep(1)

        if channel.readyState == "open":
            asyncio.ensure_future(send_pings())
        else:
            @channel.on("open")
            def on_open():
                asyncio.ensure_future(send_pings())

    return pc

Next, we will implement the main routine with the following steps:

  1. Set up the producer logic
  2. Connect to the signaling server
  3. Wait for the offer and set it as the remote description
  4. Create an answer and send it to the server
  5. Keep the connection open for one minute to send the pings
  6. Close the connection

The routine is started by calling asyncio.run(producer()).

25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
async def producer():
    pc = setup_producer_logic()
    async with websockets.connect("ws://localhost:8000/ws") as ws:
        # Receive session description from consumer
        data = await ws.recv()
        obj = RTCSessionDescription(**json.loads(data))
        await pc.setRemoteDescription(obj)
        # Build and send answer
        answer = await pc.createAnswer()
        await pc.setLocalDescription(answer)
        answer_data = json.dumps({
            "sdp": pc.localDescription.sdp,
            "type": pc.localDescription.type
        })
        await ws.send(answer_data)
        print("Signalling done, keeping loop running for one minute")
        # Signalling done, keep loop running for one minute
        await asyncio.sleep(60)
        await pc.close()

asyncio.run(producer())

3. Optional: Python WebRTC client (consumer)

To test our setup, we will create a second Python WebRTC client that will create an offer, wait for an answer, and print the pings it receives.

The code is very similar to the producer. As before, we define our logic in a function so that we can execute it later when we are inside the asyncio event loop. But this time, we define the data channel. If the data channel is open and receives a message, we simply print it. The data channel always has to be included in the offer, since an RTCPeerConnection without a media track or data channel is not allowed.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
import asyncio
import json

import websockets
from aiortc import (
    RTCPeerConnection, RTCSessionDescription, 
    RTCConfiguration, RTCIceServer
)


def setup_consumer_logic():
    config = RTCConfiguration(
        iceServers=[RTCIceServer(urls="stun:stun.l.google.com:19302")]
    )
    pc = RTCPeerConnection(config)
    channel = pc.createDataChannel("data")

    @channel.on("message")
    def on_message(message):
        print("Received message:", message)

    return pc

Next, we will implement the main routine with the following steps:

  1. Set up the consumer logic
  2. Connect to the signaling server
  3. Create offer, gather ICE Candidates, and send the offer to the server
  4. Wait for the answer and set it as the remote description
  5. Keep the connection open for one minute to print the pings
  6. Close the connection

The routine is started by calling asyncio.run(consumer()).

21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
async def consumer():
    pc = setup_consumer_logic()
    async with websockets.connect("ws://localhost:8000/ws") as ws:
        # Set offer locally, aiortc will automatically gather ICE candidates
        await pc.setLocalDescription(await pc.createOffer())
        # Send offer to server via our websocket
        offer_data = json.dumps({
            "sdp": pc.localDescription.sdp,
            "type": pc.localDescription.type
        })
        await ws.send(offer_data)
        # Wait for answer
        data = await ws.recv()
        obj = RTCSessionDescription(**json.loads(data))
        await pc.setRemoteDescription(obj)
        # Signalling done, keep loop running for one minute
        print("Signalling done, keeping loop running for one minute")
        await asyncio.sleep(60)
        await pc.close()


asyncio.run(consumer())

Now we can run the server, producer, and consumer and see the pings being printed in the consumer. Start them in this order, as we did not implement any error handling or retries. It’s best to start them in three different terminals so you can see the output of each client.

1
2
3
4
5
6
# Shell 1
fastapi dev
# Shell 2
python consumer.py
# Shell 3
python producer.py

The output of the consumer should look as follows:

1
2
3
4
Received message: ping 1721948133
Received message: ping 1721948134
Received message: ping 1721948135
Received message: ping 1721948136

If you don’t see any output, enable logging in your producer and consumer:

1
2
import logging
logging.basicConfig(level=logging.DEBUG)

If it works, you can also try running one of the clients on a different machine or network, by changing the localhost to the IP of the machine running the server. and setting --host <your-ip> or --host 0.0.0.0 when starting the FastAPI server.

But since we want to use WebRTC in the browser, we will now move to the javascript part.

4. JavaScript WebRTC client

To keep it simple, I tried to port the python code from the consumer to javascript as closely as possible.

First, we will create a new RTCPeerConnection and a data channel. We will add an event listener to the data channel to print the pings we receive.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
function setupConsumerLogic() {
    const config = {
        iceServers: [{urls: ['stun:stun.l.google.com:19302']}],
    };
    
    const pc = new RTCPeerConnection(config);
    const dataChannel = pc.createDataChannel('data');

    dataChannel.onmessage = event => {
        console.log('Received: ' + event.data);
    }
    return pc
}

Next, we will implement the main routine with the following steps:

  1. Set up the consumer logic
  2. Connect to the signaling server
  3. Build the local offer
  4. Wait for the ICE candidates to be gathered
  5. Send the offer including the ICE candidates to the server
  6. Wait for the answer and set it as the remote description

The main difference from the Python code is that we have to wait explicitly for the ICE candidates to be gathered. This is due to the flexibility of the JS WebRTC API, which allows sending the ICE candidates after the offer.

15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
async function createOffer() {
    const pc = setupConsumerLogic();
    const ws = new WebSocket('/ws')
    ws.onopen = async e => {
        // Create an offer
        const offer = await pc.createOffer();
        await pc.setLocalDescription(offer);
        // Wait for ICE gathering to complete
        await new Promise((resolve) => {
            if (pc.iceGatheringState === 'complete') {
                resolve();
            } else {
                function checkState() {
                    if (pc.iceGatheringState === 'complete') {
                        pc.removeEventListener(
                            'icegatheringstatechange', 
                            checkState
                        );
                        resolve();
                    }
                }
                pc.addEventListener('icegatheringstatechange', checkState);
            }
        });
        // Finished gathering ice candidates, send offer to the server
        await ws.send(JSON.stringify(pc.localDescription));
        // Await answer
        ws.onmessage = async e => {
            const answer = JSON.parse(e.data);
            await pc.setRemoteDescription(answer);
        }
    }
}

// Run createOffer after page load
createOffer().then();

The JavaScript client is ready, but we need to serve the HTML and JavaScript via FastAPI.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
<!DOCTYPE html>
<html>
    <head>
        <title>WebRTC Data Channel Example</title>
    </head>
    <body>
        <h1>WebRTC Data Channel Example</h1>
        <script src="/webrtc_offer.js"></script>
    </body>
</html>

Save both files as index.html and webrtc_offer.js in a directory called static in the same directory as the FastAPI server. Then add the following route to the FastAPI server (app.py) to serve the HTML and JavaScript.

1
2
3
4
5
# Add this import to the top of the file
from starlette.staticfiles import StaticFiles

# Add this route to the bottom of the file
app.mount("/", StaticFiles(directory="static"), name="static")

The file structure should look like this:

1
2
3
4
5
6
7
.
├── app.py
├── consumer.py
├── producer.py
└── static
    ├── index.html
    └── webrtc_offer.js

Now you can start the FastAPI server with fastapi dev and the producer with python producer.py. Then open the browser at http://localhost:8000/. You should see the pings being printed in the browser console.

Next steps

This is a minimalistic example that only shows how to establish a connection and send and receive some data.

The next steps depend on your use case. You might want to address the following issues:

  • Error handling: We currently expect the clients and server to answer and send valid data.
  • Rooms and Roles: We currently have only one room and two roles (producer and consumer). If you want multiple connections, you need to implement rooms and roles in the server.
  • Authentication/Authorization: You can also implement authentication in the FastAPI server with OAuth and an identity provider to assign the user to a specific room and role.

I hope this tutorial was helpful to you. There will soon be a second part where I will measure the latency and throughput of the connection.


  1. This signaling does not stop once the connection is established, but is also used to renegotiate the connection, for example, when a new stream is added or removed. ↩︎

  2. https://github.com/aiortc/aioice/issues/4 ↩︎