Chapter 04 · Cloudflare Deploy
Subchapter 4.175
references/realtime-sfu/gotchas.mdMarkdown5 KBView on GitHub
Cause: First STUN delayed during consensus forming (normal behavior) Solution: Subsequent connections are faster. CF detects DTLS ClientHello early to compensate.
Cause: SDP exchange incomplete, connection not established, tracks not added before offer, browser permissions missing Solution:
pc.connectionState === 'connected'chrome://webrtc-internals for debuggingCause: Track not published, track ID not shared, session IDs mismatch, pc.ontrack not set, renegotiation needed
Solution:
pc.ontrack handler before answerCause: Network changed, firewall blocked UDP, TURN needed, transient network issue Solution:
pc.oniceconnectionstatechange = async () => {
if (pc.iceConnectionState === 'failed') {
console.warn('ICE failed, attempting restart');
await pc.restartIce(); // Triggers new ICE gathering
// Create new offer with ICE restart flag
const offer = await pc.createOffer({iceRestart: true});
await pc.setLocalDescription(offer);
// Send to backend → Cloudflare API
await fetch(`/api/sessions/${sessionId}/renegotiate`, {
method: 'PUT',
body: JSON.stringify({sdp: offer.sdp})
});
}
};Cause: Sender paused track, network congestion, codec mismatch, mobile browser backgrounded Solution:
track.enabled and track.readyState === 'live'pc.getSenders().find(s => s.track === track)Cause: Mobile switching WiFi↔cellular, laptop changing networks Solution:
// Listen for network changes
if ('connection' in navigator) {
(navigator as any).connection.addEventListener('change', async () => {
console.log('Network changed');
await pc.restartIce(); // Use ICE restart pattern above
});
}
// Or use PartyTracks (handles automatically)async function fetchWithRetry(url: string, options: RequestInit, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
try {
const res = await fetch(url, options);
if (res.ok) return res;
if (res.status >= 500) throw new Error('Server error');
return res; // Client error, don't retry
} catch (err) {
if (i === maxRetries - 1) throw err;
const delay = Math.min(1000 * 2 ** i, 10000); // Cap at 10s
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}chrome://webrtc-internals in Chrome/Edgesucceeded state, relay vs host candidatesiceConnectionState, connectionState changes| Resource/Limit | Value | Notes |
|---|---|---|
| Egress (Free) | 1TB/month | Per account |
| Egress (Paid) | $0.05/GB | After free tier |
| Inbound traffic | Free | All plans |
| TURN service | Free | Included with SFU |
| Participants | No hard limit | Client bandwidth/CPU bound (typically 10-50 tracks) |
| Tracks per session | No hard limit | Client resources limited |
| Session duration | No hard limit | Production calls run for hours |
| WebRTC ports | UDP 1024-65535 | Outbound only, required for media |
| API rate limit | 600 req/min | Per app, burst allowed |
CALLS_APP_SECRET to client