webrtc-patterns
WebRTC patterns — peer connection setup, ICE/STUN/TURN configuration, signaling server design, SFU vs mesh topology, screen sharing, media track management, and reconnect/ICE restart handling.
Best use case
webrtc-patterns is best used when you need a repeatable AI agent workflow instead of a one-off prompt.
WebRTC patterns — peer connection setup, ICE/STUN/TURN configuration, signaling server design, SFU vs mesh topology, screen sharing, media track management, and reconnect/ICE restart handling.
Teams using webrtc-patterns should expect a more consistent output, faster repeated execution, less prompt rewriting.
When to use this skill
- You want a reusable workflow that can be run more than once with consistent structure.
When not to use this skill
- You only need a quick one-off answer and do not need a reusable workflow.
- You cannot install or maintain the underlying files, dependencies, or repository context.
Installation
Claude Code / Cursor / Codex
Manual Installation
- Download SKILL.md from GitHub
- Place it in
.claude/skills/webrtc-patterns/SKILL.mdinside your project - Restart your AI agent — it will auto-discover the skill
How webrtc-patterns Compares
| Feature / Agent | webrtc-patterns | Standard Approach |
|---|---|---|
| Platform Support | Not specified | Limited / Varies |
| Context Awareness | High | Baseline |
| Installation Complexity | Unknown | N/A |
Frequently Asked Questions
What does this skill do?
WebRTC patterns — peer connection setup, ICE/STUN/TURN configuration, signaling server design, SFU vs mesh topology, screen sharing, media track management, and reconnect/ICE restart handling.
Where can I find the source code?
You can find the source code on GitHub using the link provided at the top of the page.
SKILL.md Source
# WebRTC Patterns
## When to Activate
- Building real-time audio/video calls (1:1 or group)
- Implementing screen sharing
- Building a live streaming feature with low latency (<500ms)
- Choosing between P2P, SFU (LiveKit, Mediasoup), or MCU architectures
- Deploying TURN servers for NAT traversal
- Designing WebRTC signaling with WebSocket
- Debugging ICE connection failures or poor video quality
---
## WebRTC Core Concepts
WebRTC is a browser/native API for peer-to-peer real-time audio, video, and data. Three core APIs:
| API | Purpose |
|-----|---------|
| `RTCPeerConnection` | Manages the peer connection — media, codec negotiation, ICE |
| `RTCDataChannel` | Arbitrary binary/text data between peers (without a server) |
| `MediaStream` | Audio/video track container from camera, microphone, or screen |
**What WebRTC does NOT define:** signaling. The mechanism to exchange offer/answer/ICE is left to the application — typically WebSocket.
---
## ICE, STUN, and TURN
```
Browser A ─────── STUN Server ──── discovers public IP
│
│ ICE Candidates exchanged via Signaling (WebSocket)
│
Browser B ─────── STUN Server ──── discovers public IP
│
└──────── Direct P2P connection (if NAT allows) ────────┘
OR
└──── TURN Server (relay) ── if P2P impossible (symmetric NAT) ──┘
```
- **STUN** (Session Traversal Utilities for NAT): Browser asks "what's my public IP:port?" — free to run, minimal bandwidth
- **TURN** (Traversal Using Relays around NAT): Full media relay through server — required for ~15-20% of connections behind corporate/symmetric NAT
- **ICE** (Interactive Connectivity Establishment): Tries all possible connection paths (host, srflx, relay) and selects the best one
---
## Offer/Answer Flow (Signaling)
```javascript
// ──── CALLER (Browser A) ────────────────────────────────────────────────────
const pc = new RTCPeerConnection({ iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'turn:turn.myapp.com:3478', username: 'user', credential: 'pass' },
]});
// Add local media tracks
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
stream.getTracks().forEach(track => pc.addTrack(track, stream));
// Create Offer
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
// Send offer via signaling channel
signalingSocket.emit('offer', { to: remoteUserId, sdp: offer });
// Trickle ICE: send candidates as they arrive (don't wait for all)
pc.onicecandidate = ({ candidate }) => {
if (candidate) signalingSocket.emit('ice-candidate', { to: remoteUserId, candidate });
};
// Receive Answer
signalingSocket.on('answer', async ({ sdp }) => {
await pc.setRemoteDescription(new RTCSessionDescription(sdp));
});
// Receive remote ICE candidates
signalingSocket.on('ice-candidate', async ({ candidate }) => {
await pc.addIceCandidate(new RTCIceCandidate(candidate));
});
// Receive remote stream
pc.ontrack = ({ streams }) => {
remoteVideoElement.srcObject = streams[0];
};
// ──── CALLEE (Browser B) ────────────────────────────────────────────────────
signalingSocket.on('offer', async ({ from, sdp }) => {
await pc.setRemoteDescription(new RTCSessionDescription(sdp));
const localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
localStream.getTracks().forEach(track => pc.addTrack(track, localStream));
const answer = await pc.createAnswer();
await pc.setLocalDescription(answer);
signalingSocket.emit('answer', { to: from, sdp: answer });
});
```
---
## Media Server Architectures
### P2P Mesh (no server)
- Every participant connects to every other: N*(N-1)/2 connections
- Scales to ~4-6 participants before bandwidth and CPU collapse
- Best for: 1:1 calls, small groups with good network
### SFU — Selective Forwarding Unit (recommended for group calls)
- Each participant sends one upstream to the SFU; receives N-1 downstreams from it
- Server does NOT decode/re-encode (just forwards) → low server CPU
- Scales to 50-200 active speakers in one room with simulcast
- Best for: group video calls, webinars, live streaming
### MCU — Multipoint Control Unit (rarely used)
- Server decodes all streams, mixes them into one composite, re-encodes and sends it
- Client bandwidth: only one stream regardless of participants
- Tradeoff: very high server CPU + cost, encoding delay, lower quality
- Best for: SIP gateway, legacy systems, very limited client bandwidth
---
## LiveKit (Modern SFU — Recommended)
[LiveKit](https://livekit.io) is an open-source SFU written in Go. Production-ready, batteries-included.
### Architecture
```
┌─────────────────┐
Client App ───────► │ LiveKit Server │ ◄────── Access Token (JWT)
(Web/iOS/Android) │ (SFU in Go) │
└────────┬────────┘
│ Egress API
┌────▼────┐
│ S3 │ (recording)
└─────────┘
```
### Server Setup
```bash
# Docker (development)
docker run -d \
-p 7880:7880 \ # HTTP/gRPC
-p 7881:7881 \ # TLS
-p 7882:7882/udp \ # RTC/UDP
-e LIVEKIT_KEYS="devkey: devsecret" \
livekit/livekit-server:latest
# Kubernetes (production)
helm repo add livekit https://helm.livekit.io
helm install livekit livekit/livekit-server \
--set livekit.keys.mykey=mysecret \
--set livekit.turn.enabled=true
```
### Access Tokens (Server-Side)
```typescript
// Generate JWT token — never share LiveKit API key with clients
import { AccessToken } from 'livekit-server-sdk';
async function createRoomToken(
roomName: string,
participantIdentity: string,
participantName: string,
): Promise<string> {
const token = new AccessToken(
process.env.LIVEKIT_API_KEY!,
process.env.LIVEKIT_API_SECRET!,
{
identity: participantIdentity,
name: participantName,
ttl: '2h', // token expires in 2 hours
},
);
token.addGrant({
room: roomName,
roomJoin: true,
canPublish: true,
canSubscribe: true,
canPublishData: true,
});
return token.toJwt();
}
// REST endpoint: GET /api/rooms/:roomId/token
app.get('/api/rooms/:roomId/token', authenticate, async (req, res) => {
const token = await createRoomToken(
req.params.roomId,
req.user.id,
req.user.displayName,
);
res.json({ data: { token, serverUrl: process.env.LIVEKIT_URL } });
});
```
### Client Integration (Web)
```typescript
import { Room, RoomEvent, RemoteParticipant, Track } from 'livekit-client';
async function joinRoom(serverUrl: string, token: string) {
const room = new Room({
adaptiveStream: true, // adjust quality to bandwidth
dynacast: true, // only send simulcast layers that are subscribed
videoCaptureDefaults: {
resolution: VideoPresets.h720.resolution,
},
});
// Event handlers
room
.on(RoomEvent.TrackSubscribed, (track, publication, participant) => {
if (track.kind === Track.Kind.Video) {
const videoElement = document.getElementById(`video-${participant.identity}`);
track.attach(videoElement as HTMLVideoElement);
}
})
.on(RoomEvent.ParticipantConnected, (participant: RemoteParticipant) => {
console.log('Participant joined:', participant.identity);
})
.on(RoomEvent.Disconnected, (reason) => {
console.log('Disconnected from room:', reason);
// Implement reconnection logic
});
// Connect to room
await room.connect(serverUrl, token);
// Publish local camera + microphone
await room.localParticipant.enableCameraAndMicrophone();
return room;
}
// Clean up
function leaveRoom(room: Room) {
room.disconnect();
}
```
### LiveKit iOS/Android SDKs
```swift
// iOS (Swift)
import LiveKit
let room = Room()
let url = "wss://my-livekit-server.com"
let token = await fetchTokenFromAPI()
try await room.connect(url, token)
try await room.localParticipant.setCamera(enabled: true)
try await room.localParticipant.setMicrophone(enabled: true)
```
```kotlin
// Android (Kotlin)
val room = Room(context)
room.connect("wss://my-livekit-server.com", token) { result ->
result.onSuccess {
room.localParticipant.setMicrophoneEnabled(true)
room.localParticipant.setCameraEnabled(true)
}
}
```
---
## Mediasoup (Low-Level SFU)
[Mediasoup](https://mediasoup.org) is a Node.js SFU that gives you full control but requires building your own signaling protocol. Choose Mediasoup over LiveKit when:
- You need complete control over the media pipeline
- You want to process/inspect media server-side (recording at RTP level)
- You need ORTC compatibility
```javascript
// Mediasoup server — simplified
const mediasoup = require('mediasoup');
const worker = await mediasoup.createWorker({ 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, 'profile-level-id': '42e01f' } },
],
});
// Create WebRTC transport for each participant
const transport = await router.createWebRtcTransport({
listenIps: [{ ip: '0.0.0.0', announcedIp: process.env.ANNOUNCED_IP }],
enableUdp: true,
enableTcp: true,
preferUdp: true,
});
// Producer (sending peer) connects and produces
const producer = await transport.produce({ kind: 'video', rtpParameters });
// Consumer (receiving peer) connects and consumes
const consumer = await transport.consume({
producerId: producer.id,
rtpCapabilities: peerRtpCapabilities,
});
```
---
## Simulcast
Simulcast allows a sender to transmit multiple quality layers (e.g., 1080p + 540p + 270p) simultaneously. The SFU forwards only the layer appropriate for each receiver's bandwidth.
```javascript
// Publish with simulcast (Web)
const videoTrack = await navigator.mediaDevices.getUserMedia({ video: true });
pc.addTransceiver(videoTrack, {
sendEncodings: [
{ rid: 'high', maxBitrate: 1_200_000, scaleResolutionDownBy: 1 },
{ rid: 'medium', maxBitrate: 400_000, scaleResolutionDownBy: 2 },
{ rid: 'low', maxBitrate: 100_000, scaleResolutionDownBy: 4 },
],
});
// LiveKit enables simulcast automatically:
room.localParticipant.publishVideoTrack(track, {
simulcast: true,
videoSimulcastLayers: [VideoPresets.h1080, VideoPresets.h540, VideoPresets.h216],
});
```
---
## TURN Server Deployment (Coturn)
TURN is required for ~15-20% of users behind corporate firewalls or symmetric NAT. Without it, those calls fail.
```bash
# Install Coturn (Ubuntu)
apt-get install coturn
# /etc/turnserver.conf
listening-port=3478
tls-listening-port=5349
listening-ip=0.0.0.0
relay-ip=YOUR_PUBLIC_IP
external-ip=YOUR_PUBLIC_IP
realm=turn.myapp.com
server-name=turn.myapp.com
lt-cred-mech
user=username:password
# For time-limited credentials (HMAC):
use-auth-secret
static-auth-secret=your-secret-key
# TLS
cert=/etc/letsencrypt/live/turn.myapp.com/fullchain.pem
pkey=/etc/letsencrypt/live/turn.myapp.com/privkey.pem
```
**Time-limited TURN credentials (more secure — no static password):**
```typescript
// Generate TURN credentials server-side, valid for 1 hour
function generateTurnCredentials(userId: string): { username: string; credential: string } {
const timestamp = Math.floor(Date.now() / 1000) + 3600; // expires in 1h
const username = `${timestamp}:${userId}`;
const credential = createHmac('sha1', process.env.TURN_SECRET!)
.update(username)
.digest('base64');
return { username, credential };
}
// Include in ICE servers config
const iceServers = [
{ urls: 'stun:stun.l.google.com:19302' },
{
urls: ['turn:turn.myapp.com:3478', 'turns:turn.myapp.com:5349'],
...generateTurnCredentials(userId),
},
];
```
**Alternatives to self-hosted TURN:**
- Twilio Network Traversal Service (pay-per-use)
- Cloudflare Calls (includes TURN)
- Xirsys TURN-as-a-service
---
## Recording with LiveKit Egress
```typescript
import { EgressClient, EncodedFileOutput, RoomCompositeEgressRequest } from 'livekit-server-sdk';
const egress = new EgressClient(process.env.LIVEKIT_URL!, process.env.LIVEKIT_API_KEY!, process.env.LIVEKIT_API_SECRET!);
// Record entire room to S3
const egressInfo = await egress.startRoomCompositeEgress(roomName, {
file: new EncodedFileOutput({
filepath: `recordings/${roomName}-${Date.now()}.mp4`,
output: {
case: 's3',
value: {
accessKey: process.env.AWS_ACCESS_KEY_ID!,
secret: process.env.AWS_SECRET_ACCESS_KEY!,
bucket: 'my-recordings-bucket',
region: 'us-east-1',
},
},
}),
});
// Stop recording
await egress.stopEgress(egressInfo.egressId);
```
---
## Testing WebRTC
```bash
# Chrome flags for fake media (useful in Playwright/Puppeteer E2E tests)
chromium --use-fake-ui-for-media-stream \
--use-fake-device-for-media-stream \
--allow-file-access-from-files
# LiveKit load test
livekit-cli load-test \
--url wss://my-server.com \
--api-key devkey --api-secret devsecret \
--room load-test-room \
--publishers 10 \
--subscribers 50 \
--duration 60s
```
**Playwright WebRTC test:**
```typescript
// playwright.config.ts — grant camera/mic permissions
use: {
permissions: ['camera', 'microphone'],
launchOptions: {
args: ['--use-fake-ui-for-media-stream', '--use-fake-device-for-media-stream'],
},
},
```
---
## Security Checklist
```
- [ ] Access tokens are short-lived (max 2-4 hours) and participant-scoped
- [ ] TURN credentials use HMAC time-limited method (not static passwords)
- [ ] Room creation is server-side only (clients cannot create arbitrary rooms)
- [ ] Recording requires explicit participant consent (GDPR)
- [ ] Room permissions enforced server-side (canPublish, canSubscribe per role)
- [ ] TURN server uses TLS (turns://) not plaintext (turn://) in production
- [ ] LiveKit API key/secret is server-side only — never in client bundle
```
---
## Reference
- Skill: `realtime-patterns` — WebSockets, SSE, when to choose WebRTC vs WebSocket
- Command: `webrtc-review` — architectural review checklist for WebRTC systems
- [LiveKit documentation](https://docs.livekit.io)
- [Mediasoup documentation](https://mediasoup.org/documentation/)
- [Coturn wiki](https://github.com/coturn/coturn/wiki)Related Skills
zero-trust-patterns
Zero-Trust security patterns — mTLS between microservices (Istio/SPIFFE), SPIRE workload identity, OPA/Envoy authorization, NetworkPolicy default-deny-all, short-lived credentials, service mesh security, and Kubernetes RBAC hardening.
webhook-patterns
Webhook patterns for receiving, verifying (HMAC), and idempotently processing third-party events. Covers Stripe, GitHub, and generic webhook patterns, delivery guarantees, retry handling, and testing.
wasm-patterns
WebAssembly patterns: wasm-pack, wasm-bindgen (JS↔Wasm interop), WASI, Component Model, wasm-opt, Rust-to-WASM compilation, JS integration (web workers, streaming instantiation), and production deployment (CDN, Content-Type headers).
ux-micro-patterns
UX micro-patterns for every product state: Empty States, Loading States (skeleton screens, spinners, optimistic UI), Error States, Success States, Confirmation Dialogs, Onboarding Flows, and Progressive Disclosure. These patterns apply to every feature — done wrong, they're the biggest source of user confusion.
typescript-patterns
TypeScript patterns — type system best practices, strict mode, utility types, generics, discriminated unions, error handling with Result types, and module organization. Core patterns for production TypeScript.
typescript-patterns-advanced
Advanced TypeScript — mapped types, template literal types, conditional types, infer, type guards, decorators, async patterns, testing with Vitest/Jest, and performance. Extends typescript-patterns.
typescript-monorepo-patterns
TypeScript monorepo patterns with Turborepo + pnpm workspaces. Covers package structure, shared configs, task pipeline caching, build orchestration, and publishing strategy.
terraform-patterns
Infrastructure as Code with Terraform — project structure, remote state, modules, workspace strategy, AWS/GCP patterns, CI/CD integration, and security hardening. The standard for managing production infrastructure.
swiftui-patterns
SwiftUI architecture patterns, state management with @Observable, view composition, navigation, performance optimization, and modern iOS/macOS UI best practices.
swift-patterns
Core Swift patterns — value vs reference types, protocols, generics, optionals, Result, error handling, Codable, and module organization. Foundation for all Swift development.
swift-patterns-advanced
Advanced Swift patterns — property wrappers, result builders, Combine basics, opaque & existential types, macro system, advanced generics, and performance optimization. Extends swift-patterns.
storybook-patterns
Storybook patterns: CSF3 (meta satisfies Meta, play functions, @storybook/test), addon ecosystem (a11y, interactions, docs), MSW integration for API mocking, Chromatic CI, storybook-test-runner for Jest/Playwright execution, and Storybook as living documentation.