TL;DR
- A video call is really five systems in a row: camera capture, codec negotiation (SDP), NAT traversal (ICE/STUN/TURN), a signaling channel, and connection repair.
- You need
react-native-webrtc, a signaling server that just relays JSON, STUN + TURN, and camera/mic permissions on both platforms. - It does not run in Expo Go. Use a dev build (EAS) with
@config-plugins/react-native-webrtc, or bare RN. - Calls stuck at ICE
checkingare a TURN problem nine times out of ten. Budget ~1.35 GB of TURN per fully relayed hour at 720p. - Mesh is fine for 1-to-1 and maybe 3-4 people. Past that, reach for an SFU like LiveKit or mediasoup.
Pick your lane first: raw WebRTC or a managed SDK
WebRTC is a protocol, not a product. You either own the stack or buy pieces of it.
| Option | You build | You pay for | Good for |
|---|---|---|---|
| Raw WebRTC + self-hosted signaling | Signaling, STUN/TURN, call state | Infra, debugging, TURN bandwidth | Learning, custom protocols, low volume |
| WebRTC + managed TURN (Twilio, Xirsys) | Signaling, call logic | TURN bandwidth per GB | Production 1-to-1 at modest scale |
| Managed SDK (Agora, Daily, Twilio Video, Dyte) | UI integration | Per-minute fees | Shipping this week |
What I see most: people start raw to learn, then switch to a managed SDK the moment they need group calls, recording or noise suppression. That's fine. For a 1-to-1 app, raw WebRTC is the right call. For a Zoom clone, buy the stack. This post goes the raw route because once you get it, every other option makes sense.
The signaling dance in 6 steps
Media goes phone-to-phone. Your server never touches audio or video. It only helps the two peers find each other and agree on codecs:
- Caller creates an
RTCPeerConnectionand attaches the local camera/mic stream. - Caller creates an SDP offer (codecs, resolutions, capabilities) and sends it through signaling.
- Callee creates its own
RTCPeerConnection, sets the offer as remote description, and sends back an SDP answer. - Both sides gather ICE candidates (possible network paths) and trickle them through signaling.
- Candidates are tried in priority order: host, srflx (via STUN), relay (via TURN).
- One works, the peer connection is up, signaling goes quiet, video flows.
If any step fails silently, both users see "ringing" and then nothing. Debugging WebRTC is basically logging every phase of this.
Setup
# Bare React Native
npm install react-native-webrtc
cd ios && pod install && cd ..
# Expo (dev build / EAS only, not Expo Go)
npx expo install react-native-webrtc @config-plugins/react-native-webrtc
Expo config plugin in app.json:
{
"expo": {
"plugins": [
[
"@config-plugins/react-native-webrtc",
{
"cameraPermission": "Allow $(PRODUCT_NAME) to access your camera for video calls",
"microphonePermission": "Allow $(PRODUCT_NAME) to access your microphone for calls"
}
]
]
}
}
Then npx expo prebuild and eas build --profile development. If it crashes in Expo Go, that's expected: Expo Go can't load custom native modules.
Bare RN, iOS ios/<YourApp>/Info.plist:
<key>NSCameraUsageDescription</key>
<string>We need the camera for video calls</string>
<key>NSMicrophoneUsageDescription</key>
<string>We need the microphone for calls</string>
Android android/app/src/main/AndroidManifest.xml:
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
On Android you also need runtime permission requests before touching the camera (PermissionsAndroid).
Grab the local camera stream
import { mediaDevices, MediaStream, RTCView } from 'react-native-webrtc';
import { useEffect, useState } from 'react';
export function CallScreen() {
const [localStream, setLocalStream] = useState<MediaStream | null>(null);
useEffect(() => {
async function startLocalStream() {
const stream = await mediaDevices.getUserMedia({
audio: true,
video: {
width: 640,
height: 480,
frameRate: 30,
facingMode: 'user', // front camera
},
});
setLocalStream(stream);
}
startLocalStream();
return () => {
localStream?.getTracks().forEach((t) => t.stop());
};
}, []);
if (!localStream) return null;
return (
<RTCView
streamURL={localStream.toURL()}
style={{ flex: 1 }}
objectFit="cover"
mirror
/>
);
}
RTCView takes a streamURL, not the stream object. Stop the tracks on unmount, or the camera light stays on and users notice fast.
The peer connection (caller side)
import {
RTCPeerConnection,
RTCSessionDescription,
RTCIceCandidate,
} from 'react-native-webrtc';
const configuration = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{
urls: 'turn:your-turn-server.com:3478',
username: 'user',
credential: 'pass',
},
],
};
export async function startCall(localStream: MediaStream, signaling: SignalingClient) {
const pc = new RTCPeerConnection(configuration);
// Attach local tracks
localStream.getTracks().forEach((track) => {
pc.addTrack(track, localStream);
});
// Remote stream handler
pc.addEventListener('track', (event) => {
const [remoteStream] = event.streams;
// Pass remoteStream up to render in another <RTCView />
});
// Trickle ICE candidates to the signaling channel
pc.addEventListener('icecandidate', (event) => {
if (event.candidate) {
signaling.send({ type: 'ice-candidate', candidate: event.candidate });
}
});
// Create and send the SDP offer
const offer = await pc.createOffer({ offerToReceiveVideo: true, offerToReceiveAudio: true });
await pc.setLocalDescription(offer);
signaling.send({ type: 'offer', sdp: offer });
// Handle incoming answer and ICE from callee
signaling.on('answer', async (msg) => {
await pc.setRemoteDescription(new RTCSessionDescription(msg.sdp));
});
signaling.on('ice-candidate', async (msg) => {
await pc.addIceCandidate(new RTCIceCandidate(msg.candidate));
});
return pc;
}
The callee is the mirror: take the offer, setRemoteDescription(offer), createAnswer(), setLocalDescription(answer), send it back. Both sides keep swapping ICE candidates as they show up.
A signaling server in ~20 lines
Anything that relays JSON works: WebSocket, Socket.io, Firebase, Supabase Realtime. Socket.io is the clearest:
// server/signaling.js
const { createServer } = require('http');
const { Server } = require('socket.io');
const io = new Server(createServer().listen(3001), { cors: { origin: '*' } });
io.on('connection', (socket) => {
socket.on('join', (roomId) => {
socket.join(roomId);
const peers = Array.from(io.sockets.adapter.rooms.get(roomId) ?? []);
socket.emit('peers', peers.filter((id) => id !== socket.id));
});
socket.on('signal', ({ to, payload }) => {
io.to(to).emit('signal', { from: socket.id, payload });
});
socket.on('disconnect', () => {
socket.rooms.forEach((roomId) => socket.to(roomId).emit('peer-left', socket.id));
});
});
It's a dumb relay on purpose. Cheap to scale, and media is already encrypted end to end by WebRTC's SRTP.
For production, add JWT auth on the handshake, room access control, and persisted signals for callees who are offline when the call rings. Real call ringing means PushKit + CallKit on iOS and high-priority FCM + ConnectionService on Android, which is its own rabbit hole.
STUN, TURN, and the hotel Wi-Fi problem
STUN lets a peer discover its public IP. Google's stun:stun.l.google.com:19302 is free and fine for dev. But roughly 15% of users sit behind symmetric NATs (hotel Wi-Fi, corporate networks, carrier CGNAT) where STUN alone can't make a path. They need TURN, which relays the media itself.
TURN costs bandwidth. Your options:
- Self-host coturn: cheapest at scale, needs DevOps time.
- Managed TURN (Twilio Network Traversal, Xirsys, Metered): a few dollars per GB, live in minutes.
- Hybrid: self-hosted for most traffic, managed as fallback.
Napkin math: 720p/30fps is ~1.5 Mbps each way, so one fully relayed hour is about 1.35 GB.
const configuration = {
iceServers: [
{ urls: 'stun:stun.l.google.com:19302' },
{
urls: ['turn:turn.example.com:3478', 'turns:turn.example.com:5349'],
username: timeLimitedUsername,
credential: timeLimitedCredential,
},
],
iceCandidatePoolSize: 10,
};
Rotate TURN credentials and never ship long-lived ones in the app. Most providers use the HMAC scheme from RFC 7635.
Render the remote stream + controls
<View style={styles.container}>
<RTCView
streamURL={remoteStream.toURL()}
style={styles.remoteVideo}
objectFit="cover"
/>
<RTCView
streamURL={localStream.toURL()}
style={styles.pictureInPicture}
objectFit="cover"
mirror
zOrder={1} // Android: render above remote view
/>
</View>
Without zOrder={1} on Android, your picture-in-picture hides behind the remote view. iOS uses tree order, so it just works.
// Mute mic
localStream.getAudioTracks()[0].enabled = false;
// Toggle video
localStream.getVideoTracks()[0].enabled = false;
// Switch between front and back camera
localStream.getVideoTracks()[0]._switchCamera();
// End the call
pc.close();
localStream.getTracks().forEach((t) => t.stop());
signaling.send({ type: 'bye' });
enabled = false doesn't renegotiate SDP, so it's cheaper than removing the track. _switchCamera() is a react-native-webrtc extra; on the web you'd call getUserMedia again.
What breaks in production
- Simulator has no camera. Test video on real devices.
- Vague permission strings get rejected. "App needs camera" fails App Store review. "Needed for video calling with contacts" passes.
-
ICE stuck at
checking. Candidates were swapped but none worked. Almost always TURN. Log it:
pc.addEventListener('iceconnectionstatechange', () => {
console.log('ICE state:', pc.iceConnectionState);
});
-
Audio dies in the background. iOS: add
audiotoUIBackgroundModes. Android: run a foreground service during calls. -
One side sees video, the other doesn't. SDP direction mismatch, usually
offerToReceiveVideo: falseoraddTrackcalled aftercreateOffer. Check fora=sendrecvon each media line.
The boring screens around the call
The call is the hard part. Login, contacts, call history, settings and notifications are standard mobile UI, and they still eat weeks. I'd rather spend that time on TURN configs. RapidNative generates those screens as real React Native + Expo code from a prompt, with a live preview on your phone, and you export the project and drop the WebRTC code from this post into it.
Going past 1-to-1
Mesh scales badly: every extra person adds another upload per device. A 4-person call uploads your video three times, and by 5 most mobile uplinks are done.
- Mesh (what we built): fine up to 3-4 people on good networks.
- SFU: each client uploads once and the server forwards. Scales to ~50. Open source: mediasoup, LiveKit, Janus. Managed: Daily, Dyte, 100ms.
- MCU: the server mixes everything into one stream. Light on clients, heavy on servers, rare these days.
Need group calls? Start with LiveKit or mediasoup. Writing your own SFU is a six-month detour.
More reading: react-native-webrtc community docs and the W3C WebRTC spec.
What's your setup for TURN in production: coturn, a managed provider, or a mix? And has anyone found a clean way to handle CallKit ringing with react-native-webrtc? Drop it in the comments 👇
Top comments (0)