RTCPeerConnection, ICE/STUN/TURN, MediaStream, Data Channels und SFU-Skalierung — Claude Code als WebRTC-Experte für echte Peer-to-Peer-Kommunikation im Browser.
WebRTC (Web Real-Time Communication) ermöglicht direkte Browser-zu-Browser-Kommunikation ohne Zwischenserver für die eigentlichen Mediadaten. Die W3C-Spezifikation ist seit 2021 ein offizieller Standard und wird von allen modernen Browsern nativ unterstützt — kein Plugin, kein Flash, kein nativer App-Wrapper nötig.
Die zentrale API ist RTCPeerConnection. Sie kapselt die gesamte Verbindungslogik: ICE-Candidate-Sammlung, DTLS-Handshake, SRTP-Verschlüsselung und Mediamultiplexing über BUNDLE. Claude Code generiert die vollständige Boilerplate inklusive korrektem Error-Handling und State-Machine-Logik.
WebRTC nutzt das SDP-Protokoll (Session Description Protocol) für den Capability-Austausch. Der Call-Flow folgt immer demselben Muster: Caller erstellt ein Offer-SDP, Callee antwortet mit einem Answer-SDP. Danach werden ICE Candidates ausgetauscht bis eine Verbindung steht.
// Peer A: Offer erstellen und senden
const configuration = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' }
]
};
const pc = new RTCPeerConnection(configuration);
// ICE Candidates lokal sammeln und zum Signaling-Server senden
pc.onicecandidate = (event) => {
if (event.candidate) {
socket.emit('ice-candidate', {
roomId,
candidate: event.candidate
});
}
};
// Verbindungsstatus überwachen
pc.onconnectionstatechange = () => {
console.log('Connection state:', pc.connectionState);
if (pc.connectionState === 'failed') {
pc.restartIce(); // Automatischer Reconnect-Versuch
}
};
// Offer erstellen
const offer = await pc.createOffer({
offerToReceiveAudio: true,
offerToReceiveVideo: true
});
// Local Description setzen (startet ICE-Gathering)
await pc.setLocalDescription(offer);
// Offer über Signaling-Server zu Peer B senden
socket.emit('offer', { roomId, sdp: pc.localDescription });
RTCPeerConnection Lifecycle:
new — Initial, keine Verbindung
connecting — ICE-Candidates werden geprüft, DTLS-Handshake läuft
connected — Mindestens ein ICE-Candidate-Paar funktioniert, DTLS fertig
disconnected — Heartbeats fehlen, aber Reconnect möglich (kurze Unterbrechung)
failed — Alle ICE-Kandidaten gescheitert → restartIce() nötig
closed — pc.close() explizit aufgerufen
// Peer B: Offer empfangen und Answer senden
socket.on('offer', async ({ sdp }) => {
const pc = new RTCPeerConnection(configuration);
pc.onicecandidate = (event) => {
if (event.candidate) {
socket.emit('ice-candidate', { roomId, candidate: event.candidate });
}
};
// Remote Track eingehend — Video im UI anzeigen
pc.ontrack = (event) => {
const [remoteStream] = event.streams;
remoteVideoElement.srcObject = remoteStream;
};
// Remote Description aus dem Offer setzen
await pc.setRemoteDescription(new RTCSessionDescription(sdp));
// Answer erstellen und setzen
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
// Answer zurück zu Peer A
socket.emit('answer', { roomId, sdp: pc.localDescription });
});
WebRTC selbst definiert keinen Signaling-Mechanismus — das ist bewusst flexibel gehalten. In der Praxis werden WebSockets (über Socket.io oder native WebSocket-API) als Signaling-Kanal eingesetzt. Der Signaling-Server vermittelt SDP-Offers, SDP-Answers und ICE-Candidates zwischen den Peers, übermittelt aber niemals die eigentlichen Mediadaten.
// server.js — Minimaler Signaling Server
import { createServer } from 'http';
import { Server } from 'socket.io';
const httpServer = createServer();
const io = new Server(httpServer, {
cors: { origin: 'https://deine-app.com', methods: ['GET', 'POST'] }
});
// Rooms: Schlüssel = roomId, Wert = Set von Socket-IDs
const rooms = new Map();
io.on('connection', (socket) => {
console.log(`Client verbunden: ${socket.id}`);
// Room betreten
socket.on('join-room', (roomId) => {
const room = rooms.get(roomId) ?? new Set();
if (room.size >= 2) {
socket.emit('room-full');
return;
}
room.add(socket.id);
rooms.set(roomId, room);
socket.join(roomId);
// Alle anderen im Room benachrichtigen
socket.to(roomId).emit('peer-joined', { peerId: socket.id });
socket.emit('joined-room', {
roomId,
peers: [...room].filter(id => id !== socket.id)
});
});
// SDP Offer weiterleiten
socket.on('offer', ({ roomId, sdp, targetId }) => {
socket.to(targetId ?? roomId).emit('offer', {
sdp,
fromId: socket.id
});
});
// SDP Answer weiterleiten
socket.on('answer', ({ roomId, sdp, targetId }) => {
socket.to(targetId ?? roomId).emit('answer', {
sdp,
fromId: socket.id
});
});
// ICE Candidates weiterleiten
socket.on('ice-candidate', ({ roomId, candidate, targetId }) => {
socket.to(targetId ?? roomId).emit('ice-candidate', {
candidate,
fromId: socket.id
});
});
// Disconnect: Room aufräumen
socket.on('disconnect', () => {
rooms.forEach((members, roomId) => {
if (members.has(socket.id)) {
members.delete(socket.id);
if (members.size === 0) rooms.delete(roomId);
io.to(roomId).emit('peer-left', { peerId: socket.id });
}
});
});
});
httpServer.listen(3001, () => console.log('Signaling Server: Port 3001'));
Multi-Peer Rooms (Mesh-Topologie):
Bei mehr als 2 Peers erstellt jeder Teilnehmer eine eigene RTCPeerConnection zu jedem anderen. Bei N Peers = N*(N-1)/2 Verbindungen. Skaliert bis ca. 4-6 Teilnehmer gut, danach SFU empfohlen (siehe Abschnitt 6).
Socket.io Rooms abstrahieren das Routing — jeder Socket in einem Room bekommt Events via socket.to(roomId).emit(), der Sender selbst ist ausgenommen.
// client.js — Socket.io Client-Integration
import { io } from 'socket.io-client';
const socket = io('https://signal.deine-app.com', {
transports: ['websocket'], // Long-Polling als Fallback vermeiden
reconnection: true,
reconnectionDelay: 1000,
reconnectionAttempts: 5
});
socket.on('connect', () => {
socket.emit('join-room', roomId);
});
socket.on('peer-joined', async ({ peerId }) => {
// Wir sind der erste im Room — jetzt Offer erstellen
await createOffer(peerId);
});
socket.on('offer', async ({ sdp, fromId }) => {
await handleOffer(sdp, fromId);
});
socket.on('answer', async ({ sdp, fromId }) => {
const pc = peerConnections.get(fromId);
await pc.setRemoteDescription(new RTCSessionDescription(sdp));
});
socket.on('ice-candidate', async ({ candidate, fromId }) => {
const pc = peerConnections.get(fromId);
if (pc && candidate) {
await pc.addIceCandidate(new RTCIceCandidate(candidate));
}
});
Das größte praktische Problem bei WebRTC: Die meisten Geräte befinden sich hinter NAT (Network Address Translation) und haben keine direkt erreichbare öffentliche IP. ICE (Interactive Connectivity Establishment) löst dieses Problem durch systematisches Testen verschiedener Verbindungswege.
| Typ | Beschreibung | Erfolgsrate |
|---|---|---|
| host | Lokale IP-Adresse des Geräts | Nur im gleichen LAN |
| srflx (STUN) | Öffentliche IP via STUN-Server ermittelt | ~75% (scheitert bei sym. NAT) |
| prflx | Peer-reflexive, während ICE-Check entdeckt | Selten, automatisch |
| relay (TURN) | Traffic über TURN-Server relayed | ~100% (Fallback) |
# coturn installieren
apt-get install coturn -y
# /etc/turnserver.conf
listening-port=3478
tls-listening-port=5349
listening-ip=0.0.0.0
external-ip=DEINE_ÖFFENTLICHE_IP
relay-ip=DEINE_ÖFFENTLICHE_IP
# Auth
fingerprint
lt-cred-mech
user=webrtc:sicheres_passwort
realm=turn.deine-app.com
# TLS (Let's Encrypt)
cert=/etc/letsencrypt/live/turn.deine-app.com/fullchain.pem
pkey=/etc/letsencrypt/live/turn.deine-app.com/privkey.pem
# Logging
log-file=/var/log/coturn/turnserver.log
verbose
# coturn starten
systemctl enable coturn
systemctl start coturn
iceServers Konfiguration im Client:
const configuration = {
iceServers: [
// Google STUN — kostenlos, kein Auth
{ urls: 'stun:stun.l.google.com:19302' },
// Eigener TURN — Fallback für sym. NAT
{
urls: [
'turn:turn.deine-app.com:3478',
'turn:turn.deine-app.com:3478?transport=tcp',
'turns:turn.deine-app.com:5349' // TLS
],
username: 'webrtc',
credential: 'sicheres_passwort'
}
],
iceTransportPolicy: 'all', // 'relay' = nur TURN erzwingen
bundlePolicy: 'max-bundle',
rtcpMuxPolicy: 'require'
};
// server.js — Temporäre TURN-Credentials (HMAC-basiert)
import crypto from 'crypto';
function generateTurnCredentials(secret, ttlSeconds = 3600) {
const unixTimestamp = Math.floor(Date.now() / 1000) + ttlSeconds;
const username = `${unixTimestamp}:webrtc-user`;
const credential = crypto
.createHmac('sha1', secret)
.update(username)
.digest('base64');
return { username, credential };
}
// REST Endpoint: Client fragt temporäre Credentials an
app.get('/api/turn-credentials', (req, res) => {
const credentials = generateTurnCredentials(process.env.TURN_SECRET);
res.json({
iceServers: [{
urls: ['turn:turn.deine-app.com:3478'],
...credentials
}]
});
});
Die MediaStream API liefert Audio- und Video-Tracks vom Mikrofon, von der Kamera oder vom Bildschirm. Die Tracks werden der RTCPeerConnection hinzugefügt und dann automatisch verschlüsselt und zum Remote-Peer übertragen.
// Kamera und Mikrofon anfordern
async function getLocalStream() {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 1280, max: 1920 },
height: { ideal: 720, max: 1080 },
frameRate: { ideal: 30 },
facingMode: 'user'
},
audio: {
echoCancellation: true,
noiseSuppression: true,
sampleRate: 48000,
channelCount: 1
}
});
// Lokales Video ohne Ton (Feedback vermeiden)
localVideo.srcObject = stream;
localVideo.muted = true;
return stream;
} catch (err) {
if (err.name === 'NotAllowedError') {
showError('Kamera-Zugriff verweigert. Bitte Browserberechtigungen prüfen.');
} else if (err.name === 'NotFoundError') {
showError('Keine Kamera gefunden.');
} else {
showError(`Fehler: ${err.message}`);
}
throw err;
}
}
// Tracks zur PeerConnection hinzufügen
const localStream = await getLocalStream();
localStream.getTracks().forEach(track => {
pc.addTrack(track, localStream);
});
// Remote Stream im Video-Element anzeigen
pc.ontrack = (event) => {
const [remoteStream] = event.streams;
if (remoteVideo.srcObject !== remoteStream) {
remoteVideo.srcObject = remoteStream;
console.log('Remote Stream empfangen', remoteStream.id);
}
// Track-Statistiken für Debug
event.track.onmute = () => console.log('Remote Track stumm');
event.track.onunmute = () => console.log('Remote Track aktiv');
};
Track-Management: Kamera/Mikrofon steuern
// Mikrofon stummschalten (Track bleibt in PeerConnection)
function toggleMute() {
const audioTrack = localStream.getAudioTracks()[0];
audioTrack.enabled = !audioTrack.enabled;
muteBtn.textContent = audioTrack.enabled ? 'Stummschalten' : 'Ton an';
}
// Kamera deaktivieren
function toggleCamera() {
const videoTrack = localStream.getVideoTracks()[0];
videoTrack.enabled = !videoTrack.enabled;
}
// Kamera wechseln (z.B. auf Rückkamera)
async function switchCamera(deviceId) {
const newStream = await navigator.mediaDevices.getUserMedia({
video: { deviceId: { exact: deviceId } }
});
const newTrack = newStream.getVideoTracks()[0];
const sender = pc.getSenders().find(s => s.track.kind === 'video');
await sender.replaceTrack(newTrack); // Kein Renegotiation nötig!
}
// Bildschirm teilen (Tab, Fenster oder gesamter Bildschirm)
async function startScreenShare() {
const screenStream = await navigator.mediaDevices.getDisplayMedia({
video: {
displaySurface: 'monitor', // 'browser', 'window', 'monitor'
logicalSurface: true,
cursor: 'always',
frameRate: { ideal: 30, max: 60 }
},
audio: true // System-Audio mitschneiden (Chrome)
});
const screenTrack = screenStream.getVideoTracks()[0];
// Sender ersetzen: Kamera → Bildschirm (kein Renegotiation)
const videoSender = pc.getSenders().find(s => s.track?.kind === 'video');
await videoSender.replaceTrack(screenTrack);
// Automatisch stoppen wenn User Browser-Button klickt
screenTrack.onended = async () => {
const cameraTrack = localStream.getVideoTracks()[0];
await videoSender.replaceTrack(cameraTrack);
console.log('Screen Share beendet, zurück zu Kamera');
};
}
// Verfügbare Eingabegeräte auflisten
const devices = await navigator.mediaDevices.enumerateDevices();
const cameras = devices.filter(d => d.kind === 'videoinput');
const microphones = devices.filter(d => d.kind === 'audioinput');
sender.setParameters() erlaubt Bitrate-Limits ohne Renegotiation — wichtig für mobile Nutzer mit schlechter Verbindung.
RTCDataChannel ermöglicht direkten Datenaustausch zwischen Peers — ohne Server-Umweg. Die Latenz ist deutlich geringer als bei WebSocket-basierten Lösungen, da die Daten direkt von Peer zu Peer fließen (wenn ICE ohne TURN funktioniert). Use Cases: Chat, Spielzüge, kollaborative Editoren, File Transfer, Binary Protocol für IoT.
// Peer A erstellt den DataChannel VOR dem Offer
const dataChannel = pc.createDataChannel('chat', {
ordered: true, // TCP-ähnlich: Reihenfolge garantiert
maxRetransmits: 3, // Max. Wiederholungen (null = unbegrenzt)
// maxPacketLifeTime: 3000, // Alternativ: Timeout in ms
});
dataChannel.onopen = () => {
console.log('DataChannel offen');
dataChannel.send('Hallo von Peer A!');
};
dataChannel.onmessage = (event) => {
const message = typeof event.data === 'string'
? event.data
: '[Binärdaten]';
console.log('Empfangen:', message);
};
dataChannel.onclose = () => console.log('DataChannel geschlossen');
dataChannel.onerror = (err) => console.error('DataChannel Fehler:', err);
// Peer B empfängt den Channel via ondatachannel
pc.ondatachannel = (event) => {
const receivedChannel = event.channel;
receivedChannel.onmessage = (e) => handleMessage(e.data);
receivedChannel.onopen = () => receivedChannel.send('Hallo von Peer B!');
};
ordered: true, maxRetransmits: null — Reliable (wie TCP). Für Chat, Dateiübertragung, Spielzüge wo Reihenfolge wichtig ist.
ordered: false, maxRetransmits: 0 — Unreliable (wie UDP). Für Echtzeit-Positions-Updates, Cursor-Bewegungen, wo verlorene Pakete akzeptabel sind.
ordered: false, maxPacketLifeTime: 100 — Time-constrained. Paket wird max. 100ms versucht zu senden — gut für Audio-Jitter-Kontrolle.
priority: 'high' — DSCP-Marking für QoS-fähige Netzwerke.
// Datei-Transfer: Sender-Seite
async function sendFile(file, channel) {
const CHUNK_SIZE = 16384; // 16 KB pro Chunk
const buffer = await file.arrayBuffer();
// Metadaten zuerst senden
channel.send(JSON.stringify({
type: 'file-meta',
name: file.name,
size: file.size,
mimeType: file.type,
chunks: Math.ceil(file.size / CHUNK_SIZE)
}));
// Chunks sequenziell senden (Buffer-Level beachten)
let offset = 0;
while (offset < buffer.byteLength) {
// Warten wenn SendBuffer voll (Backpressure)
if (channel.bufferedAmount > 65536) {
await new Promise(resolve => {
channel.onbufferedamountlow = resolve;
});
}
const chunk = buffer.slice(offset, offset + CHUNK_SIZE);
channel.send(chunk);
offset += CHUNK_SIZE;
// Fortschritt melden
const progress = Math.round((offset / buffer.byteLength) * 100);
updateProgress(progress);
}
channel.send(JSON.stringify({ type: 'file-end' }));
}
// Empfänger-Seite
let incomingFile = null;
const receivedChunks = [];
receivedChannel.onmessage = (event) => {
if (typeof event.data === 'string') {
const msg = JSON.parse(event.data);
if (msg.type === 'file-meta') {
incomingFile = msg;
receivedChunks.length = 0;
} else if (msg.type === 'file-end') {
const blob = new Blob(receivedChunks, { type: incomingFile.mimeType });
const url = URL.createObjectURL(blob);
triggerDownload(url, incomingFile.name);
}
} else {
receivedChunks.push(event.data); // ArrayBuffer Chunk
}
};
bufferedAmountLowThreshold auf 65536 setzen für effiziente Backpressure.
Für echte Production-Apps reicht der direkte RTCPeerConnection-Code meist aus — aber es gibt Abstraktions-Libraries und Skalierungsstrategien die den Entwicklungsaufwand erheblich reduzieren.
// npm install simple-peer
import SimplePeer from 'simple-peer';
// Initiator-Seite (erstellt Offer automatisch)
const peer = new SimplePeer({
initiator: true,
trickle: true, // Trickle ICE für schnelleren Connect
stream: localStream,
config: {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'turn:turn.deine-app.com:3478',
username: 'webrtc', credential: 'passwort' }
]
}
});
// Signal-Event: SDP + ICE Candidates gebündelt
peer.on('signal', (data) => {
socket.emit('signal', { roomId, data });
});
peer.on('stream', (remoteStream) => {
remoteVideo.srcObject = remoteStream;
});
peer.on('data', (data) => {
console.log('DataChannel Nachricht:', data.toString());
});
peer.on('error', (err) => console.error('simple-peer Fehler:', err));
peer.on('close', () => console.log('Verbindung getrennt'));
// Signal von Remote weitergeben
socket.on('signal', ({ data }) => peer.signal(data));
Ab 4-6 Teilnehmern wird die Mesh-Topologie zu ineffizient — jeder Peer sendet N-1 Video-Streams. Ein SFU (Selective Forwarding Unit) nimmt einen Stream pro Teilnehmer an und verteilt ihn selektiv weiter. Der Server entschlüsselt die Mediadaten dabei nicht — SRTP bleibt Ende-zu-Ende.
mediasoup v3 Architektur:
Worker — Node.js Worker-Thread, verwaltet Transports und Routers auf CPU-Kern
Router — Media-Routing-Kontext (ein Router = ein "Room" / eine Sitzung)
WebRtcTransport — Client-seitige Verbindung (je ein Transport für Send + Receive)
Producer — Eingehender Media-Stream vom Client zum SFU
Consumer — Ausgehender Media-Stream vom SFU zum Client
// server.js — mediasoup SFU Setup (vereinfacht)
import * as mediasoup from 'mediasoup';
const worker = await mediasoup.createWorker({
rtcMinPort: 10000,
rtcMaxPort: 10200,
logLevel: 'warn'
});
const router = await worker.createRouter({
mediaCodecs: [
{ kind: 'audio', mimeType: 'audio/opus', clockRate: 48000, channels: 2 },
{ kind: 'video', mimeType: 'video/VP8', clockRate: 90000 },
{ kind: 'video', mimeType: 'video/H264', clockRate: 90000,
parameters: { 'packetization-mode': 1, 'level-asymmetry-allowed': 1 } }
]
});
// Client verbindet sich: Transport erstellen
socket.on('create-transport', async ({ direction }, callback) => {
const transport = await router.createWebRtcTransport({
listenIps: [{ ip: '0.0.0.0', announcedIp: process.env.PUBLIC_IP }],
enableUdp: true,
enableTcp: true,
preferUdp: true
});
callback({
id: transport.id,
iceParameters: transport.iceParameters,
iceCandidates: transport.iceCandidates,
dtlsParameters: transport.dtlsParameters
});
});
// Robuste Reconnection-Logik für Production
class WebRTCConnection {
constructor(config) {
this.config = config;
this.pc = null;
this.reconnectAttempts = 0;
this.maxReconnects = 5;
}
async connect() {
this.pc = new RTCPeerConnection(this.config);
this.pc.onconnectionstatechange = async () => {
const state = this.pc.connectionState;
console.log(`Connection: ${state}`);
if (state === 'disconnected') {
// Kurz warten — evtl. erholt sich die Verbindung von selbst
setTimeout(() => {
if (this.pc.connectionState !== 'connected') {
this.pc.restartIce();
}
}, 3000);
}
if (state === 'failed') {
if (this.reconnectAttempts < this.maxReconnects) {
this.reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
console.log(`Reconnect in ${delay}ms (Versuch ${this.reconnectAttempts})`);
setTimeout(() => this.reconnect(), delay);
} else {
this.onFatalError?.();
}
}
if (state === 'connected') {
this.reconnectAttempts = 0;
}
};
}
async reconnect() {
this.pc.close();
await this.connect();
// Neues Offer erstellen und über Signaling senden
await this.createAndSendOffer();
}
}
// RTCStatsReport — Verbindungsqualität überwachen
async function getConnectionStats(pc) {
const stats = await pc.getStats();
const result = {};
stats.forEach(report => {
if (report.type === 'inbound-rtp' && report.kind === 'video') {
result.video = {
packetsLost: report.packetsLost,
packetsReceived: report.packetsReceived,
bytesReceived: report.bytesReceived,
framesDecoded: report.framesDecoded,
jitter: report.jitter
};
}
if (report.type === 'candidate-pair' && report.state === 'succeeded') {
result.network = {
rtt: report.currentRoundTripTime * 1000, // ms
availableOutgoingBitrate: report.availableOutgoingBitrate,
bytesSent: report.bytesSent
};
}
});
return result;
}
// Alle 5 Sekunden Statistiken loggen
setInterval(async () => {
const stats = await getConnectionStats(pc);
if (stats.network?.rtt > 300) {
console.warn(`Hohe Latenz: ${stats.network.rtt}ms`);
}
}, 5000);
Production Checklist 2026:
TURN-Server: Eigener coturn auf eigenem VPS — nie nur Google STUN vertrauen (scheitert bei ~25% der Nutzer)
Signaling-Security: JWT-Auth auf Socket.io, Rate Limiting, Room-Size-Limits
HTTPS/WSS: getUserMedia funktioniert nur über HTTPS (außer localhost)
Mobile: iOS Safari ab 15.4 unterstützt Screen Sharing; Android Chrome voll unterstützt
Codec-Fallback: VP8 als Baseline, VP9/H264/AV1 wo verfügbar
SFU ab 4+ Peers: mediasoup (Node.js), Janus, LiveKit oder Livekit Cloud
Von der minimalen 1:1-Videocall-App bis zum skalierbaren SFU-Setup mit mediasoup — Claude Code generiert production-ready WebRTC-Code auf Deutsch, inkl. TURN-Server-Konfiguration und Reconnection-Logic.
Kostenlos starten