WebRTC media flows peer to peer and never touches your FastAPI server. What FastAPI provides is the signaling channel that lets two peers find each other, usually over WebSockets.
The most common confusion here is expecting FastAPI to carry the video. It does not, and that is the point of WebRTC — media travels directly between peers over UDP, negotiated once and then out of your application’s hands.
Your server has two jobs. It relays the connection offers and ICE candidates so peers can negotiate, and it hands out credentials for the STUN and TURN servers that make connections work across NATs. Both are small. Neither is optional.
Table of contents
- What your server is actually responsible for
- A signaling server in FastAPI
- Handing out TURN credentials
- When the server needs to be a peer: aiortc
- Scaling past one process
- How this fits the rest of the stack
- FAQ
What your server is actually responsible for
WebRTC deliberately leaves signaling undefined. The specification covers how peers exchange media once connected, not how they discover each other, so every application builds that part.
- Signaling — relaying SDP offers and answers, plus ICE candidates, between two peers. This is what FastAPI does, almost always over a WebSocket.
- STUN — lets a peer discover its own public address behind NAT. Cheap, stateless, and public servers exist.
- TURN — relays media when a direct connection cannot be established. Bandwidth-intensive, and you have to run or buy it.
- Media — flows directly between peers over UDP. Your server never sees it, unless TURN is relaying.
The number worth planning around: roughly 10 to 20 percent of connections need TURN, because symmetric NATs and restrictive corporate firewalls block direct paths. Without TURN those users simply cannot connect, and it presents as an intermittent bug you cannot reproduce.
A signaling server in FastAPI
The core is a WebSocket endpoint that keeps track of who is in which room and forwards messages between them. The server does not interpret the SDP — it is a relay.
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from collections import defaultdict
import json
app = FastAPI()
rooms: dict[str, dict[str, WebSocket]] = defaultdict(dict)
@app.websocket("/ws/{room_id}/{peer_id}")
async def signaling(ws: WebSocket, room_id: str, peer_id: str):
await ws.accept()
room = rooms[room_id]
# Tell the newcomer who is already here
await ws.send_text(json.dumps({
"type": "peers",
"peers": list(room.keys()),
}))
room[peer_id] = ws
try:
while True:
message = json.loads(await ws.receive_text())
target = message.get("target")
message["from"] = peer_id
if target and target in room:
await room[target].send_text(json.dumps(message))
else:
for pid, peer_ws in room.items():
if pid != peer_id:
await peer_ws.send_text(json.dumps(message))
except WebSocketDisconnect:
pass
finally:
room.pop(peer_id, None)
for peer_ws in room.values():
await peer_ws.send_text(json.dumps({"type": "left", "peer": peer_id}))
if not room:
rooms.pop(room_id, None)
The finally block matters. Without cleanup, disconnected peers accumulate in the room dictionary and new joiners try to negotiate with sockets that are already closed.
This in-memory dictionary works for exactly one server process. The moment you run two workers, peers connected to different processes cannot see each other — covered below.
Handing out TURN credentials
Never hardcode TURN credentials in client JavaScript. Anyone can read them and use your relay bandwidth. Generate short-lived credentials server-side instead, using the standard HMAC scheme that coturn supports.
import hashlib
import hmac
import base64
import time
import os
from fastapi import Depends
TURN_SECRET = os.environ["TURN_STATIC_AUTH_SECRET"].encode()
TURN_HOST = os.environ["TURN_HOST"]
@app.get("/api/ice-servers")
async def ice_servers(user=Depends(current_user)):
# Username is an expiry timestamp; the password is its HMAC
expiry = int(time.time()) + 3600
username = f"{expiry}:{user.id}"
password = base64.b64encode(
hmac.new(TURN_SECRET, username.encode(), hashlib.sha1).digest()
).decode()
return {
"iceServers": [
{"urls": f"stun:{TURN_HOST}:3478"},
{
"urls": [
f"turn:{TURN_HOST}:3478?transport=udp",
f"turns:{TURN_HOST}:5349?transport=tcp",
],
"username": username,
"credential": password,
},
]
}
The credentials expire in an hour and are tied to an authenticated user, so a leaked pair is worth very little. This is the standard coturn REST authentication scheme.
Include the TLS turns: entry on port 5349. Corporate firewalls that block UDP entirely will often still allow TLS on a standard-looking port, and it is frequently the only path that works from inside a locked-down network.
When the server needs to be a peer: aiortc
Everything so far assumes browser-to-browser media. If Python itself must receive or produce media — recording, server-side processing, feeding frames to a model — you need aiortc, which is a full WebRTC implementation for Python.
pip install aiortc av
from aiortc import RTCPeerConnection, RTCSessionDescription
from fastapi import FastAPI
from pydantic import BaseModel
pcs: set[RTCPeerConnection] = set()
class Offer(BaseModel):
sdp: str
type: str
@app.post("/api/offer")
async def offer(params: Offer):
pc = RTCPeerConnection()
pcs.add(pc)
@pc.on("connectionstatechange")
async def on_state_change():
if pc.connectionState in ("failed", "closed"):
await pc.close()
pcs.discard(pc)
@pc.on("track")
def on_track(track):
if track.kind == "video":
asyncio.ensure_future(consume_video(track))
await pc.setRemoteDescription(RTCSessionDescription(params.sdp, params.type))
answer = await pc.createAnswer()
await pc.setLocalDescription(answer)
return {
"sdp": pc.localDescription.sdp,
"type": pc.localDescription.type,
}
@app.on_event("shutdown")
async def on_shutdown():
await asyncio.gather(*(pc.close() for pc in pcs))
pcs.clear()
Note this uses a plain HTTP POST rather than WebSockets. When the server is one of the two peers, a single offer-answer exchange is enough — there is no third party to relay to.
Be realistic about the cost. Decoding video in Python is expensive, and each connection consumes meaningful CPU. aiortc is excellent for a handful of connections doing genuine processing; it is not how you build a hundred-participant conference.
Scaling past one process
The in-memory room dictionary breaks as soon as you run more than one worker, and that is the first thing that happens in production.
import redis.asyncio as redis
r = redis.from_url(os.environ["REDIS_URL"])
@app.websocket("/ws/{room_id}/{peer_id}")
async def signaling(ws: WebSocket, room_id: str, peer_id: str):
await ws.accept()
pubsub = r.pubsub()
await pubsub.subscribe(f"room:{room_id}")
async def forward():
async for msg in pubsub.listen():
if msg["type"] != "message":
continue
payload = json.loads(msg["data"])
if payload.get("from") != peer_id:
await ws.send_text(msg["data"])
task = asyncio.create_task(forward())
try:
while True:
message = json.loads(await ws.receive_text())
message["from"] = peer_id
await r.publish(f"room:{room_id}", json.dumps(message))
except WebSocketDisconnect:
pass
finally:
task.cancel()
await pubsub.unsubscribe(f"room:{room_id}")
Redis pub/sub means any worker can serve any peer. Your load balancer also needs to support WebSocket upgrades and, ideally, sticky sessions — a WebSocket that gets balanced to a different process mid-connection simply drops.
The other scaling limit is topology, not process count. Peer-to-peer mesh means every participant sends a stream to every other, so a five-person call is twenty streams and it degrades quickly past that. Beyond about four participants you need an SFU, which is dedicated media-server infrastructure rather than something you add to FastAPI.
How this fits the rest of the stack
A WebRTC application is several services with different shapes: a signaling endpoint holding long-lived WebSockets, a Redis instance coordinating across workers, and a TURN server whose cost is bandwidth rather than compute. Sizing that honestly before building is worth more than optimising it afterwards. RunxBuild runs the FastAPI service and managed Redis on one private network, and the RunxBuild hosting calculator shows the service, the cache, and the bandwidth as separate line items — which is the right way to look at a workload where relayed media is the variable that moves.
Useful related references:
- Render 部署 FastAPI 项目: The Deployment Checklist That Actually Matters
- FastAPI WebSocket: Build a Connection That Survives Production
- FastAPI Logging That Survives Production: A Working Developer’s Guide
- Services on RunxBuild
FAQ
Does WebRTC media go through my FastAPI server?
No. Media flows directly between peers over UDP. FastAPI handles signaling — relaying SDP offers, answers, and ICE candidates. The exception is TURN, which relays media when a direct connection is impossible.
Do I need a TURN server?
For production, yes. Roughly 10 to 20 percent of connections cannot establish a direct path because of symmetric NAT or restrictive firewalls. Without TURN those users silently fail to connect, which is hard to reproduce and easy to miss in testing.
When do I need aiortc?
Only when Python itself must be a peer — recording streams, processing frames, or feeding media to a model. For browser-to-browser calls, your server only relays signaling and never touches the media.
Why does signaling break when I run multiple workers?
In-memory room state is per-process, so peers on different workers cannot see each other. Use Redis pub/sub to share signaling messages across workers, and make sure the load balancer supports WebSocket upgrades.
How many participants can peer-to-peer WebRTC handle?
Around four before mesh topology degrades, since every participant sends a stream to every other. Larger calls need an SFU, which is dedicated media-server infrastructure rather than an addition to your API.