DEV Community

Hugo Rus
Hugo Rus

Posted on • Originally published at rapidnative.com

How to Build a Video Calling App with React Native and WebRTC

  • WebRTC is built into every modern OS, and react-native-webrtc is production-hardened — you can ship a 1-to-1 video call in a weekend with open-source pieces only.
  • Every call has four parts: a signaling server (Socket.io), a STUN server (Google's free one), an optional TURN server (for the ~15–20% of calls that can't go P2P), and the peer connection on each device.
  • This guide builds all of it: signaling, media capture, offer/answer exchange, ICE handling, and the standard call controls.
  • The hardest part isn't the API — it's the network. Test on real cellular, log ICE state transitions, and don't skip TURN in production.

Ten years ago, adding video calling to a mobile app meant licensing a proprietary SDK, paying per-minute fees, and hoping the vendor didn't disappear. Today, WebRTC is built into every modern OS, react-native-webrtc is production-hardened, and you can ship a working 1-to-1 video call in a weekend. The catch? Nobody tells you the whole story. Most tutorials skip signaling, gloss over Expo compatibility, or quietly funnel you into a paid SDK before you write any real WebRTC code.

This guide walks through the entire flow — from a fresh React Native project to two phones streaming video and audio to each other over a real signaling server. You'll build a video calling app using open-source pieces: react-native-webrtc, Socket.io for signaling, and Google's public STUN server. No SDK lock-in, no per-minute pricing, no black boxes.

What is WebRTC and why use it in React Native?

WebRTC (Web Real-Time Communication) is an open standard that lets browsers and native apps exchange audio, video, and arbitrary data directly between peers with sub-second latency. In React Native, the react-native-webrtc library exposes the same JavaScript APIs you'd use in a browser — RTCPeerConnection, MediaStream, getUserMedia — but backed by native iOS and Android WebRTC bindings that hit the hardware encoder.

That matters for three reasons:

  • Latency. WebRTC targets sub-500ms glass-to-glass. HTTP streaming protocols like HLS sit at 6–30 seconds. For conversation, WebRTC is the only game in town.
  • Peer-to-peer by default. For 1-to-1 calls, media flows directly between devices. You don't pay for bandwidth on your server.
  • No vendor lock-in. The protocol is a W3C standard. You can switch signaling servers, TURN providers, or client libraries without rewriting your app.

The tradeoff: WebRTC hands you plumbing, not a product. You supply the signaling channel, the connection lifecycle, and the UI. That's exactly what we'll build.

The four pieces of every WebRTC call

Before the code, the mental model. Every video calling app has four moving parts, and every bug you'll ever hit lives in one of them.

Component What it does What we'll use
Signaling server Exchanges connection metadata (SDP offers/answers and ICE candidates) between peers before the call starts. Not part of WebRTC. Node.js + Socket.io
STUN server Helps a peer discover its public IP so peers behind NAT can find each other. Google's free stun:stun.l.google.com:19302
TURN server Relays media when direct P2P fails (~10–20% of calls in the wild). Skipped for the tutorial; production notes below.
Peer connection The RTCPeerConnection object on each device that negotiates and streams media. react-native-webrtc

The dance goes like this: Device A creates an "offer" (an SDP blob describing its media capabilities), sends it through the signaling server to Device B. Device B answers. Both sides then trade ICE candidates — possible network paths — until they find one that works. Media starts flowing.

Prerequisites

  • Node.js 20+ and either npm or yarn
  • React Native 0.74+ or Expo SDK 51+ (with Expo Dev Client — Expo Go does not support native WebRTC modules)
  • Xcode 15+ for iOS builds, Android Studio with API 30+ for Android
  • Two physical devices for testing. The iOS Simulator has no camera, and the Android emulator's virtual camera is unreliable for WebRTC.

If you're on Expo, make sure you're on the Expo Dev Client workflow, not Expo Go. react-native-webrtc ships native code that Expo Go can't load.

Step 1: Install and configure react-native-webrtc

Create your project and install the library:

npx create-expo-app@latest video-call-tutorial
cd video-call-tutorial
npx expo install react-native-webrtc @config-plugins/react-native-webrtc
npm install socket.io-client
Enter fullscreen mode Exit fullscreen mode

The @config-plugins/react-native-webrtc package handles platform permissions for you. Add it to your 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 video calls."
        }
      ]
    ]
  }
}
Enter fullscreen mode Exit fullscreen mode

On bare React Native, add these permissions manually:

  • iOS (ios/YourApp/Info.plist): NSCameraUsageDescription and NSMicrophoneUsageDescription
  • Android (android/app/src/main/AndroidManifest.xml): CAMERA, RECORD_AUDIO, MODIFY_AUDIO_SETTINGS, BLUETOOTH_CONNECT

Now rebuild the native app — you cannot use hot reload for this step:

npx expo prebuild
npx expo run:ios     # or run:android
Enter fullscreen mode Exit fullscreen mode

Step 2: Build a minimal signaling server

WebRTC gives you the media pipe. It doesn't tell peers how to find each other. You need a signaling server whose only job is to relay three types of messages: offers, answers, and ICE candidates.

Create a server/ folder alongside your app with this index.js:

const { Server } = require('socket.io');
const io = new Server(3001, { cors: { origin: '*' } });

const rooms = new Map();

io.on('connection', (socket) => {
  socket.on('join', (roomId) => {
    socket.join(roomId);
    const peers = rooms.get(roomId) ?? [];
    peers.push(socket.id);
    rooms.set(roomId, peers);
    // Tell this socket about existing peers so it knows to send the offer
    socket.emit('peers', peers.filter((id) => id !== socket.id));
  });

  socket.on('offer', ({ to, sdp }) => {
    io.to(to).emit('offer', { from: socket.id, sdp });
  });

  socket.on('answer', ({ to, sdp }) => {
    io.to(to).emit('answer', { from: socket.id, sdp });
  });

  socket.on('ice', ({ to, candidate }) => {
    io.to(to).emit('ice', { from: socket.id, candidate });
  });

  socket.on('disconnect', () => {
    for (const [roomId, peers] of rooms) {
      const filtered = peers.filter((id) => id !== socket.id);
      if (filtered.length) rooms.set(roomId, filtered);
      else rooms.delete(roomId);
    }
  });
});

console.log('Signaling server running on :3001');
Enter fullscreen mode Exit fullscreen mode

Install and run:

cd server
npm init -y
npm install socket.io
node index.js
Enter fullscreen mode Exit fullscreen mode

That's the entire signaling server. It's 30 lines because it should be. Anything more complex belongs in your app layer.

Step 3: Capture local media

Back in your app, request camera and microphone access. This uses mediaDevices.getUserMedia, which returns a MediaStream you can preview and later attach to the peer connection.

import { mediaDevices, MediaStream } from 'react-native-webrtc';

async function getLocalStream(): Promise<MediaStream> {
  const stream = await mediaDevices.getUserMedia({
    audio: true,
    video: {
      facingMode: 'user',
      width: { ideal: 1280 },
      height: { ideal: 720 },
      frameRate: { ideal: 30 },
    },
  });
  return stream;
}
Enter fullscreen mode Exit fullscreen mode

Render it with RTCView:

import { RTCView } from 'react-native-webrtc';

<RTCView
  streamURL={localStream.toURL()}
  style={{ flex: 1 }}
  objectFit="cover"
  mirror
/>
Enter fullscreen mode Exit fullscreen mode

mirror flips the preview horizontally, which is what users expect for the front camera. On the remote video, leave it off.

Step 4: Create the peer connection

The RTCPeerConnection object is the heart of the call. Give it your ICE server config, add your local tracks, and wire up the event handlers.

import {
  RTCPeerConnection,
  RTCSessionDescription,
  RTCIceCandidate,
} from 'react-native-webrtc';

const pc = new RTCPeerConnection({
  iceServers: [
    { urls: 'stun:stun.l.google.com:19302' },
    // In production, add a TURN server here (see below)
  ],
});

localStream.getTracks().forEach((track) => {
  pc.addTrack(track, localStream);
});

pc.addEventListener('track', (event) => {
  setRemoteStream(event.streams[0]);
});

pc.addEventListener('icecandidate', (event) => {
  if (event.candidate) {
    socket.emit('ice', { to: remotePeerId, candidate: event.candidate });
  }
});

pc.addEventListener('connectionstatechange', () => {
  console.log('PC state:', pc.connectionState);
  if (pc.connectionState === 'failed') {
    // Reconnect or tear down
  }
});
Enter fullscreen mode Exit fullscreen mode

Two things worth noting:

  1. addTrack, not addStream. addStream is deprecated in the Unified Plan SDP semantic that all modern WebRTC implementations use.
  2. Trickle ICE. The icecandidate handler fires as candidates are discovered. Send each one immediately rather than waiting for gathering to finish — this shaves seconds off call setup.

Step 5: Exchange offer and answer

The device that initiates the call creates the offer. The receiving device answers. Both sets of SDP are relayed through your signaling server.

Caller:

const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
socket.emit('offer', { to: remotePeerId, sdp: offer });
Enter fullscreen mode Exit fullscreen mode

Callee (on receiving the offer):

socket.on('offer', async ({ from, sdp }) => {
  await pc.setRemoteDescription(new RTCSessionDescription(sdp));
  const answer = await pc.createAnswer();
  await pc.setLocalDescription(answer);
  socket.emit('answer', { to: from, sdp: answer });
});
Enter fullscreen mode Exit fullscreen mode

Caller (on receiving the answer):

socket.on('answer', async ({ sdp }) => {
  await pc.setRemoteDescription(new RTCSessionDescription(sdp));
});
Enter fullscreen mode Exit fullscreen mode

Both sides handle incoming ICE candidates:

socket.on('ice', async ({ candidate }) => {
  try {
    await pc.addIceCandidate(new RTCIceCandidate(candidate));
  } catch (err) {
    console.error('addIceCandidate failed', err);
  }
});
Enter fullscreen mode Exit fullscreen mode

At this point, if you run the app on two devices that can reach each other over the network, video and audio start flowing. You've built a working WebRTC call.

Step 6: Add the call controls

A working call isn't a shippable feature. Users expect mute, video toggle, camera flip, and hangup. All of them are one or two lines with the WebRTC API.

Mute audio:

function toggleMute() {
  localStream.getAudioTracks().forEach((t) => (t.enabled = !t.enabled));
}
Enter fullscreen mode Exit fullscreen mode

Toggle video:

function toggleVideo() {
  localStream.getVideoTracks().forEach((t) => (t.enabled = !t.enabled));
}
Enter fullscreen mode Exit fullscreen mode

Switch camera:

function switchCamera() {
  localStream.getVideoTracks().forEach((t) => t._switchCamera());
}
Enter fullscreen mode Exit fullscreen mode

Hang up:

function hangup() {
  localStream.getTracks().forEach((t) => t.stop());
  pc.close();
  socket.emit('leave');
}
Enter fullscreen mode Exit fullscreen mode

Note: setting track.enabled = false keeps the transport open and sends silence/black frames — much faster to un-mute than fully removing the track.

Common pitfalls (and how to avoid them)

After shipping WebRTC in production a few times, these are the traps engineers walk into.

"It works on Wi-Fi but not on cellular." You need a TURN server. Symmetric NAT on carrier networks blocks direct P2P for a significant chunk of your users. Skipping TURN in tests is fine; skipping it in production leaves 15–20% of calls broken.

"The remote video is black on iOS." Almost always an RTCView rendering issue — the stream URL updated but the component didn't re-render. Wrap <RTCView> in a key={remoteStream.toURL()} to force remount when the stream changes.

"Audio works but video doesn't." Check that both peers actually added a video track before createOffer was called. SDP is negotiated once — if the offer only advertised audio, no video will flow even if you add a video track later. Use pc.addTransceiver up front if you want to reserve the slot.

"Metro bundler complains about require.internal after installing." You installed react-native-webrtc but forgot to rebuild. Native modules can't hot-reload — always npx expo prebuild && npx expo run:ios after installing.

"Calls fail to connect but no errors log." Attach a connectionstatechange handler. WebRTC failures are silent — the connection just doesn't succeed. Also log iceConnectionState transitions; they're the fastest signal that ICE gathering is going wrong.

Scaling beyond 1-to-1

The tutorial above scales fine to two people because media flows peer-to-peer. Add a third person and you have a mesh topology: each participant maintains a peer connection to every other participant. Bandwidth on a mobile device scales O(n) with participants, and CPU scales O(n) with encoding.

Mesh works up to about 4 participants on modern phones. Beyond that, you need a Selective Forwarding Unit (SFU) — a server that receives one stream from each participant and forwards it to the others. Open-source options include mediasoup, Janus, and LiveKit. All three work with react-native-webrtc on the client — only the signaling protocol changes.

For TURN in production, run coturn on a cheap VPS or use a managed provider like Twilio's Network Traversal Service or Xirsys. Budget roughly $0.40 per GB of relayed traffic.

Frequently asked questions

Does react-native-webrtc work with Expo Go?

No. react-native-webrtc ships native iOS and Android modules that Expo Go cannot load. Move to Expo Dev Client or the bare workflow and use EAS Build (or a local expo run:ios/expo run:android) to include the native code.

How much does a WebRTC video calling app cost to run?

For 1-to-1 calls, near zero — media flows peer-to-peer. Your only costs are the signaling server (a $5/month VPS handles thousands of concurrent calls) and TURN relay for the ~15–20% of calls that fall back to relayed mode. Budget $0.40 per GB of TURN traffic.

What's the difference between STUN and TURN?

STUN helps a peer discover its own public IP so two peers behind NAT can attempt a direct connection. TURN is a fallback: when direct P2P fails (usually because both peers are behind symmetric NAT), TURN acts as a media relay. STUN is cheap and free servers exist; TURN uses your bandwidth and typically costs money.

Is WebRTC secure?

Yes. All WebRTC media is encrypted with DTLS-SRTP by default — it's not optional in the spec. The signaling channel is your responsibility; use WSS (secure WebSockets) or HTTPS for whatever you build.

Wrapping up

You now have every piece needed for a production-quality React Native video calling app: a signaling server, a peer connection lifecycle, media capture, offer/answer exchange, ICE handling, and the standard call controls. Add a TURN server, wire the UI to your app's state management, and you're 80% of the way to shipping.

The hardest part of WebRTC isn't the API — it's the network. Test on real cellular connections, log ICE state transitions, and instrument connection failures early. Everything else is UI polish — and if you'd rather move fast on that layer, RapidNative generates the call-lobby, in-call overlay, and post-call screens so you can spend your time on the ICE candidate that just failed instead.

What part of WebRTC bit you hardest — signaling, NAT traversal, or the native build? Drop it in the comments.

Top comments (0)