A FaceTime-quality call looks like one feature. Underneath, it is five systems that have to work in perfect sequence — camera capture, codec negotiation, NAT traversal, a signaling channel, and connection repair. This guide walks through building a React Native WebRTC video calling app end to end: real code, honest architectural tradeoffs, and the parts most tutorials skip. By the end you will have a working 1-to-1 call, a signaling server, and a clear path to scale beyond two participants. For the surrounding screens — login, contacts, call history — RapidNative generates production-grade React Native code from a prompt, so you can focus the hard engineering budget on the call itself.
React Native and WebRTC together ship real-time video to iOS and Android from a single codebase — Photo by Christopher Gower on Unsplash
What You Need to Build Video Calling in React Native
A React Native WebRTC video calling app needs four pieces: the react-native-webrtc library to expose native video APIs, a signaling server to exchange SDP offers and ICE candidates, STUN/TURN servers for NAT traversal, and camera and microphone permissions configured for both iOS and Android. Expo users must use the bare workflow or EAS Build with the Expo config plugin — WebRTC does not run in Expo Go.
That is the 60-second answer. The rest of this guide is why each piece exists, how they talk to each other, and how to debug the specific failure modes that will show up when you plug it all together.
WebRTC vs Managed SDKs: The Choice That Shapes Everything
Before you write a line of code, choose a lane. WebRTC is a protocol, not a product. You can either own the whole stack or buy pieces of it.
| Option | What You Build | What You Pay For | Best For |
|---|---|---|---|
| Raw WebRTC + self-hosted signaling | Your signaling server, STUN/TURN infrastructure, call state logic | 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 call fees | Teams who want to ship this week |
The pattern I see most often: people start with raw WebRTC to learn, then move to a managed SDK the moment they need group calls, recording, or background noise suppression. There is no shame in that — building an SFU (Selective Forwarding Unit) that scales to 50 participants is a product in itself. For a 1-to-1 call app, raw WebRTC is the right call. For a Zoom clone, buy the stack.
This tutorial covers the raw-WebRTC path because understanding it makes every other option legible.
The Signaling Dance: SDP, ICE, and Why Two Phones Can't Just Talk
WebRTC uses peer-to-peer video, meaning your two phones exchange media directly without routing it through your server. But before they can, they need to find each other across the public internet, agree on video codecs, and punch holes through their NATs. The signaling server mediates that negotiation. It does not touch any video or audio.
Here is the choreography for a 1-to-1 call:
-
Caller creates a
RTCPeerConnectionand attaches their local camera and microphone stream. - Caller generates an SDP offer — a text blob describing codecs, resolutions, and media capabilities — and sends it to the callee through the signaling server.
-
Callee creates its own
RTCPeerConnection, sets the offer as the remote description, and generates an SDP answer. - Both peers gather ICE candidates — network paths the other side might use to reach them — and swap them through the signaling server (this is "trickle ICE").
- The browsers try candidates in priority order (host, srflx via STUN, relay via TURN) until one works.
- A direct peer connection is established. Signaling is no longer needed. Video flows.
If any of those steps silently fails, the call appears to connect, both users see "ringing," and then nothing happens. Debugging WebRTC is 80% logging each phase of this dance.
WebRTC peer connection flow — the signaling server negotiates, but media flows directly between devices — Photo by Taylor Vick on Unsplash
Setting Up React Native WebRTC
Install the library and native dependencies first.
# Bare React Native project
npm install react-native-webrtc
cd ios && pod install && cd ..
# Expo projects (bare workflow or EAS Build only)
npx expo install react-native-webrtc @config-plugins/react-native-webrtc
For Expo projects, add the config plugin to 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 run npx expo prebuild and eas build --profile development. WebRTC will crash in Expo Go — that is not a bug, Expo Go cannot load custom native modules.
For bare React Native, add permissions to 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>
And in 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 to request runtime permissions before accessing the camera. React Native's PermissionsAndroid handles this.
Capturing the Local Camera Stream
With permissions in place, grab the local media stream using mediaDevices.getUserMedia:
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 is the native component that renders a MediaStream. It accepts a streamURL (not the stream object directly), objectFit for aspect handling, and mirror for front-camera previews. Clean up tracks in the unmount return — forgetting this leaves the camera light on, which users notice fast.
Building the Peer Connection
Once you have a local stream, create the RTCPeerConnection and attach it. Here is the 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 a mirror image: receive the offer, create its own RTCPeerConnection, setRemoteDescription(offer), create an answer, setLocalDescription(answer), send the answer back. Both sides exchange ICE candidates as they appear.
The Signaling Server
Any transport that can relay JSON between two clients works — WebSocket, Socket.io, Firebase Realtime Database, Supabase Realtime, or your own channel. For this guide, a minimal Socket.io server is clearest:
// server/signaling.js
const { createServer } = require('http');
const { Server } = require('socket.io');
const io = new Server(createServer().listen(3001), { cors: { origin: '*' } });
const rooms = new Map(); // roomId -> Set of socket IDs
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));
});
});
The server never sees SDP or ICE content — it is a dumb relay. That is deliberate: it keeps signaling cheap to scale and makes end-to-end encryption straightforward (WebRTC's SRTP handles media encryption automatically).
In production you will want authentication (JWT on the socket handshake), room access control, and persistence of pending signals for callees who are offline when the ringer fires. For push-based call ringing on iOS, you need PushKit and CallKit; on Android, FCM with high-priority messages and ConnectionService. That is a separate rabbit hole worth its own guide.
STUN, TURN, and Why Your Call Fails on Hotel Wi-Fi
STUN servers help a peer discover its public IP so another peer can reach it directly. Google's public STUN (stun:stun.l.google.com:19302) is free and fine for development. In production, roughly 15% of users sit behind symmetric NATs where STUN alone cannot establish a path — hotel Wi-Fi, corporate networks, mobile carrier CGNAT. For those, you need a TURN server that relays media through itself.
TURN is bandwidth-hungry and therefore expensive. Options:
- Self-host coturn — the open-source TURN server. Cheapest at scale, requires DevOps capacity. coturn on GitHub.
- Managed TURN (Twilio Network Traversal, Xirsys, Metered) — a few dollars per GB. Rolls into production in minutes.
- Hybrid — self-hosted for most traffic, Twilio as fallback.
Rule of thumb: a 1-to-1 call at 720p/30fps uses ~1.5 Mbps per direction. One hour of fully-relayed call ≈ 1.35 GB of TURN bandwidth. Price your plan accordingly.
Set up your iceServers with both:
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 — never hard-code long-lived ones in your mobile app. The HMAC-based scheme in RFC 7635 is the pattern most managed providers use.
A production video call combines WebRTC's protocol, platform-native rendering, and robust signaling — Photo by Yura Fresh on Unsplash
Rendering the Remote Stream
When the track event fires, you have a remote stream. Pass it up and render it in a second RTCView:
<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>
zOrder={1} is Android-specific — without it, the local picture-in-picture renders behind the full-screen remote view. iOS orders by component tree order, so it just works there.
Call Controls: Mute, Switch Camera, Hang Up
These are small methods on the stream and track objects:
// 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 on an audio track is cheaper than removing it entirely and does not renegotiate SDP. _switchCamera() is a react-native-webrtc extension — on web WebRTC you would renegotiate with a new getUserMedia call.
What Breaks in Production (and How to Debug It)
Three failure modes account for most "it works on my simulator" moments:
Simulator has no camera. iOS Simulator cannot access a real camera, and Android emulators have limited support. Test video on physical devices. Audio can be simulated on both.
App Store review requires permission strings in English. Your NSCameraUsageDescription must clearly explain why you need the camera. "App needs camera" gets rejected. "Needed for video calling with contacts" passes.
ICE connection stalls at checking. The ICE state goes new → checking → connected → completed. Stuck at checking means candidates were exchanged but none worked. Nine times out of ten this is a missing or misconfigured TURN server. Attach a listener:
pc.addEventListener('iceconnectionstatechange', () => {
console.log('ICE state:', pc.iceConnectionState);
});
Background audio stops. On iOS, add audio to UIBackgroundModes in Info.plist. On Android, run a foreground service during active calls — otherwise the OS will suspend your process.
One side sees the other but not vice versa. Almost always an SDP direction mismatch — one peer created an offer with offerToReceiveVideo: false or forgot to addTrack before createOffer. Log the SDP on both sides; look for a=sendrecv on each media line.
Scaling Beyond 1-to-1: SFU, MCU, or Mesh?
Peer-to-peer scales badly. Each additional participant adds N-1 upstreams per device — a 4-person call uploads your video three times. By 5 participants you have saturated most mobile uplinks.
Three paths forward:
- Mesh (what we built) — works up to 3-4 participants on good networks. No server-side media infra.
- SFU (Selective Forwarding Unit) — each client sends one upload; the server forwards streams to everyone else. Scales to ~50 participants. Open-source options: mediasoup, LiveKit, Janus. Managed: Daily, Dyte, 100ms.
- MCU (Multipoint Control Unit) — server mixes streams into one composite video. Lowest client CPU, highest server cost. Rare in modern stacks.
If your product needs group calls, start with LiveKit or mediasoup. Writing an SFU from scratch is a six-month detour.
Shipping the Rest of the App
The hard part of a calling app is the call. The rest — authentication, contacts, call history, settings, notifications — is standard mobile UI. That is where RapidNative saves weeks. Describe the screens in plain English and get working React Native + Expo code with real state, Supabase-backed data, and a live preview you can test on your phone via QR code. Point and edit any element, iterate in minutes, and export the full project when you are ready to drop in your WebRTC code. Teams typically ship 85% faster than hand-coding the surrounding surfaces.
For deeper reading, see the official react-native-webrtc documentation and the WebRTC specification at the W3C. For related mobile patterns, our guides on push notifications in React Native and how we built real-time collaboration cover adjacent real-time infrastructure.
Frequently Asked Questions
Does React Native WebRTC work with Expo Go?
No. react-native-webrtc is a native module and cannot run in Expo Go. You need the bare workflow or Expo's EAS Build with the @config-plugins/react-native-webrtc plugin. After adding the plugin, run expo prebuild and build a development client — it will then work exactly like a bare React Native project.
Do I really need a TURN server for a video calling app?
Yes, if you want calls to work for everyone. Roughly 15% of users sit behind symmetric NATs or firewalls where direct peer-to-peer connections fail. A TURN server relays their traffic. In development, Google's public STUN is enough; in production, pair STUN with a managed TURN provider (Twilio, Xirsys, Metered) or self-hosted coturn.
What is the difference between STUN and TURN in WebRTC?
STUN helps a peer discover its public IP so another peer can connect directly. TURN relays media through a server when direct connections fail. STUN is lightweight and nearly free; TURN consumes significant bandwidth and costs money. Every production WebRTC app configures both — STUN as the preferred path, TURN as fallback.
Build the Rest of Your App in Minutes
WebRTC is deep, specific engineering. The screens around it should not be. Describe your calling app in plain words with RapidNative, export clean React Native code, and plug in the WebRTC flow from this guide. Start free with 20 credits — no card required.
Top comments (0)