2025, Dec 27 15:00

WebRTC Empty SDP Explained: Why aiortc + Browser Failed and How Transceivers and ICE Gathering Fix It

Learn why your WebRTC offer/answer shows empty SDP in aiortc: missing video transceivers and no ICE candidates. See the exact browser/server fixes with code.

WebRTC can be cruel when your SDP shows up empty. A minimal aiortc server paired with a browser client failed with ValueError: None is not in list at setLocalDescription, and both sides produced SDP without media or network info. The offer contained no m=video section, the answer echoed that emptiness, and there were no ICE candidates. The root cause turned out to be two-fold: the browser never negotiated a video transceiver, and neither side waited for ICE gathering to finish before exchanging SDP.

Problem setup

The server accepted an offer, created an answer, and attempted to set the local description. A synthetic video track was attached server-side, but the browser didn’t advertise any media section.

import asyncio
from aiortc import RTCPeerConnection, RTCSessionDescription, VideoStreamTrack
from av import VideoFrame
import numpy as np
import uuid
import cv2
from aiohttp import web

class SimpleBallFeed(VideoStreamTrack):

    def __init__(self):
        super().__init__()
        self.w = 640
        self.h = 480
        # ... synthetic frame generation omitted

    async def recv(self):
        # ... return a VideoFrame
        return None

async def handle_offer(request):
    payload = await request.json()
    incoming = RTCSessionDescription(sdp=payload["sdp"], type=payload["type"])

    peer = RTCPeerConnection()

    # Attach local media track
    peer.addTrack(SimpleBallFeed())

    await peer.setRemoteDescription(incoming)
    created = await peer.createAnswer()
    await peer.setLocalDescription(created)

    return {
        "sdp": peer.localDescription.sdp,
        "type": peer.localDescription.type
    }

if __name__ == "__main__":

    app = web.Application()
    app.router.add_post("/offer", handle_offer)
    app.router.add_static('/', path='static', name='static')

    web.run_app(app, port=8080)

The client created an offer, set it locally, and immediately posted it to the server without ensuring media lines or candidates existed.

const rtc = new RTCPeerConnection({
    iceServers: []
});

rtc.ontrack = ev => {
    if (ev.track.kind === 'video') {
        player.srcObject = ev.streams[0];
    }
};

const created = await rtc.createOffer();
await rtc.setLocalDescription(created);

const res = await fetch('/offer', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
        sdp: rtc.localDescription.sdp,
        type: rtc.localDescription.type
    })
});

const ans = await res.json();
await rtc.setRemoteDescription(new RTCSessionDescription(ans));

Why it breaks

WebRTC only describes media you actually negotiate. On the browser side, no track or transceiver was added, so the offer didn’t include an m=video line. On top of that, the SDP was posted before ICE gathering finished, which meant no host candidates were present. Combining missing media with missing candidates produced an unusable SDP, and setLocalDescription failed with ValueError: None is not in list. In short, no transceiver meant no media section, and no ICE wait meant no network info.

The fix

The browser must explicitly declare it wants to receive video. Adding a recvonly video transceiver forces m=video into the offer. Then wait for onicecandidate to signal completion before sending the SDP. On the server, create the answer, set it locally, and pause until iceGatheringState reaches complete. With that, the SDP contains both media and host candidates, and the synthetic video renders correctly over the local network.

Server.py

import asyncio
from aiortc import RTCPeerConnection, RTCSessionDescription, VideoStreamTrack
from av import VideoFrame
import numpy as np
import cv2
from aiohttp import web

class DriftBallStream(VideoStreamTrack):
    def __init__(self):
        super().__init__()
        self.w = 640
        self.h = 480
        self.pt = np.array([100, 100], dtype=float)
        self.vel = np.array([2, 1.5], dtype=float)
        self.rad = 20

    async def recv(self):
        pts, tb = await self.next_timestamp()

        self.pt += self.vel
        for i in (0, 1):
            bound = self.w if i == 0 else self.h
            if self.pt[i] - self.rad < 0 or self.pt[i] + self.rad > bound:
                self.vel[i] *= -1

        canvas = np.zeros((self.h, self.w, 3), dtype=np.uint8)
        cv2.circle(canvas, tuple(self.pt.astype(int)), self.rad, (0, 255, 0), -1)

        frame = VideoFrame.from_ndarray(canvas, format="bgr24")
        frame.pts = pts
        frame.time_base = tb
        return frame

async def negotiate(request):
    data = await request.json()
    remote_offer = RTCSessionDescription(sdp=data['sdp'], type=data['type'])

    peer = RTCPeerConnection()
    peer.addTrack(DriftBallStream())

    await peer.setRemoteDescription(remote_offer)
    local_answer = await peer.createAnswer()
    await peer.setLocalDescription(local_answer)

    while peer.iceGatheringState != 'complete':
        await asyncio.sleep(0.1)

    return web.json_response({
        'sdp': peer.localDescription.sdp,
        'type': peer.localDescription.type
    })

if __name__ == '__main__':
    app = web.Application()
    app.router.add_post('/negotiate', negotiate)
    app.router.add_get('/', lambda req: web.FileResponse('static/index.html'))
    app.router.add_static('/static/', path='static', show_index=False)

    web.run_app(app, port=8080)

main.js

window.addEventListener('load', async () => {
  const rtcPeer = new RTCPeerConnection({ iceServers: [] });
  const vidEl = document.getElementById('view');

  rtcPeer.addTransceiver('video', { direction: 'recvonly' });

  rtcPeer.ontrack = evt => {
    if (evt.track.kind === 'video') {
      vidEl.srcObject = evt.streams[0];
    }
  };

  rtcPeer.onicecandidate = async e => {
    if (e.candidate === null) {
      const offerSdp = rtcPeer.localDescription;
      try {
        const resp = await fetch('/negotiate', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(offerSdp)
        });
        const answerSdp = await resp.json();
        await rtcPeer.setRemoteDescription(answerSdp);
      } catch (err) {
        console.error('Error sending offer:', err);
      }
    }
  };

  const createdOffer = await rtcPeer.createOffer();
  await rtcPeer.setLocalDescription(createdOffer);
});

index.html

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Bouncing Ball Stream</title>
  <style>
    body { font-family: sans-serif; text-align: center; margin-top: 2rem; }
    video { border: 1px solid #ccc; margin-top: 1rem; }
  </style>
</head>
<body>
  <h1>WebRTC Bouncing Ball</h1>
  <video id="view" width="640" height="480" controls autoplay playsinline>
    Your browser does not support HTML5 video
  </video>

  <script src="/static/main.js"></script>
</body>
</html>

Why this matters

SDP is not magic glue; it is a precise description of intent and capability. If a peer does not announce a transceiver or a track, the offer carries no media. If you exchange SDP before ICE gathering completes, no candidates are included and connectivity fails even on a LAN. Fixing both surfaces real media lines and host candidates, which is enough for a local network scenario. The result was verified both on a desktop and on a phone within the same LAN.

Conclusion

When building a minimal aiortc to browser video path, ensure the browser declares its interest with pc.addTransceiver('video', {direction: 'recvonly'}) or a track so the offer contains m=video. Then wait for ICE gathering to finish before sending the offer and before returning the answer. With those two steps in place, the answer applies cleanly, candidates appear in SDP, and your synthetic stream plays as expected over the local network.