Skip to content
Blog

WebRTC in Mobile Apps: Voice and Video

Implement real-time voice and video communication in your mobile apps with WebRTC. Learn peer connections, signaling, and ICE candidates.

Published on August 13, 2026

AI Assistant

Why Real-Time Communication Matters

Users expect instant, low-latency communication in mobile apps. Whether it’s a telehealth consultation, a gaming voice chat, or a customer support video call, WebRTC delivers peer-to-peer media streams directly between devices without plugins or third-party apps. Unlike traditional HTTP-based solutions, WebRTC operates at the transport layer, enabling sub-second latency for audio and video—critical for interactive experiences. Mobile adoption is accelerating: telehealth, remote work, and social platforms all rely on WebRTC’s open standards, which are supported natively by every major browser and mobile SDK. This post walks you through building voice and video into your mobile app with production-grade reliability.

Prerequisites

Before diving in, ensure you have the following in place:

  • Flutter SDK 3.0+ (or a native Android/iOS setup if you prefer platform-specific code)
  • A signaling server — we’ll use a simple WebSocket server in Node.js
  • STUN/TURN server access — Google’s public STUN server (stun:stun.l.google.com:19302) works for development; use a TURN server (e.g., Coturn) for production
  • Camera and microphone permissions configured in your mobile app’s manifest
  • Basic understanding of async/await patterns in Dart and WebSocket communication

For this guide, we’ll use Flutter with the flutter_webrtc package, which wraps native WebRTC APIs for both iOS and Android.

WebRTC Architecture Overview

WebRTC establishes a direct media path between two peers, but the architecture has several moving parts that must coordinate:

  1. PeerConnection — The core object that manages the connection between two peers, handling SDP (Session Description Protocol) offer/answer negotiation and ICE (Interactive Connectivity Establishment) candidate exchange.
  2. MediaStream — Captures local audio/video from the device and renders remote streams.
  3. Signaling Server — A lightweight intermediary (typically WebSocket) that relays SDP offers, answers, and ICE candidates between peers. It never touches the media itself.
  4. STUN Server — Discovers the public IP/port of a NAT’d device, enabling direct peer-to-peer connectivity.
  5. TURN Server — Acts as a media relay when direct connectivity fails (symmetric NAT, corporate firewalls).

The flow looks like this:

Peer A                  Signaling Server               Peer B
  |--- SDP Offer --------->|                             |
  |                         |--- SDP Offer ------------->|
  |                         |                             |
  |<-- SDP Answer ----------|<-- SDP Answer -------------|
  |                         |                             |
  |--- ICE Candidate ------>|                             |
  |                         |--- ICE Candidate --------->|
  |                         |                             |
  |<-- ICE Candidate -------|<-- ICE Candidate ----------|
  |                         |                             |
  |<================== P2P Media Stream =================>|

Setting Up the Flutter WebRTC Project

Initialize a new Flutter project and add the WebRTC dependency:

# pubspec.yaml
dependencies:
  flutter_webrtc: ^0.9.0
  web_socket_channel: ^2.4.0
  permission_handler: ^10.2.0

Configure platform-specific permissions:

<!-- android/app/src/main/AndroidManifest.xml -->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.RECORD_AUDIO"/>
<uses-feature android:name="android.hardware.camera"/>
<uses-feature android:name="android.hardware.camera.autofocus"/>
<!-- ios/Runner/Info.plist -->
<key>NSCameraUsageDescription</key>
<string>Camera access for video calls</string>
<key>NSMicrophoneUsageDescription</key>
<string>Microphone access for voice calls</string>

Implementing the PeerConnection

The RTCPeerConnection object is where the magic happens. You configure it with STUN/TURN servers and register event handlers for ICE candidates, remote tracks, and connection state changes.

import 'package:flutter_webrtc/flutter_webrtc.dart';
import 'package:web_socket_channel/web_socket_channel.dart';

class WebRTCService {
  RTCPeerConnection? _peerConnection;
  MediaStream? _localStream;
  final RTCVideoRenderer _localRenderer = RTCVideoRenderer();
  final RTCVideoRenderer _remoteRenderer = RTCVideoRenderer();
  WebSocketChannel? _channel;

  final Map<String, dynamic> _iceServers = {
    'iceServers': [
      {'urls': 'stun:stun.l.google.com:19302'},
      {
        'urls': 'turn:your-turn-server.com:3478',
        'username': 'user',
        'credential': 'pass'
      }
    ]
  };

  Future<void> initialize() async {
    await _localRenderer.initialize();
    await _remoteRenderer.initialize();

    _peerConnection = await createPeerConnection(_iceServers);

    _peerConnection!.onIceCandidate = (RTCIceCandidate candidate) {
      _channel?.sink.add(_encodeMessage({
        'type': 'ice-candidate',
        'candidate': {
          'candidate': candidate.candidate,
          'sdpMid': candidate.sdpMid,
          'sdpMLineIndex': candidate.sdpMLineIndex,
        }
      }));
    };

    _peerConnection!.onTrack = (RTCTrackEvent event) {
      if (event.track.kind == 'video') {
        _remoteRenderer.srcObject = event.streams[0];
      }
    };

    _peerConnection!.onConnectionState = (RTCPeerConnectionState state) {
      print('Connection state: $state');
    };

    _localStream = await navigator.mediaDevices.getUserMedia({
      'audio': true,
      'video': {'facingMode': 'user'},
    });

    _localRenderer.srcObject = _localStream;

    for (var track in _localStream!.getTracks()) {
      _peerConnection!.addTrack(track, _localStream!);
    }
  }
}

The getUserMedia call requests camera and microphone access. If permissions are denied, the promise rejects—handle this gracefully in your UI.

Creating the SDP Offer and Answer

The SDP negotiation determines which codecs, resolutions, and bandwidth parameters both peers will use. The initiator creates an offer; the receiver creates an answer.

Future<void> createOffer() async {
  final RTCSessionDescription offer = await _peerConnection!.createOffer({
    'offerToReceiveAudio': true,
    'offerToReceiveVideo': true,
  });

  await _peerConnection!.setLocalDescription(offer);

  _channel?.sink.add(_encodeMessage({
    'type': 'offer',
    'sdp': offer.sdp,
    'sdpType': offer.type,
  }));
}

Future<void> createAnswer() async {
  final RTCSessionDescription answer = await _peerConnection!.createAnswer();

  await _peerConnection!.setLocalDescription(answer);

  _channel?.sink.add(_encodeMessage({
    'type': 'answer',
    'sdp': answer.sdp,
    'sdpType': answer.type,
  }));
}

Future<void> setRemoteDescription(Map<String, dynamic> data) async {
  final remoteDesc = RTCSessionDescription(data['sdp'], data['sdpType']);
  await _peerConnection!.setRemoteDescription(remoteDesc);
}

When Peer B receives the offer, it sets it as the remote description and immediately creates an answer. The setLocalDescription call triggers ICE candidate gathering, which flows back through the signaling server.

Handling ICE Candidates

ICE candidates are discovered asynchronously as the underlying network stack probes different transport paths. Each candidate represents a potential endpoint (host, srflx, or relay) that the peer can be reached at.

Future<void> handleIceCandidate(Map<String, dynamic> data) async {
  final candidate = data['candidate'];
  final iceCandidate = RTCIceCandidate(
    candidate['candidate'],
    candidate['sdpMid'],
    candidate['sdpMLineIndex'],
  );
  await _peerConnection!.addCandidate(iceCandidate);
}

String _encodeMessage(Map<String, dynamic> message) {
  return '{"type":"${message['type']}",'
      '"sdp":"${message['sdp'] ?? ''}",'
      '"sdpType":"${message['sdpType'] ?? ''}",'
      '"candidate":${message['candidate'] != null ? '{'
          '"candidate":"${message['candidate']['candidate']}",'
          '"sdpMid":"${message['candidate']['sdpMid']}",'
          '"sdpMLineIndex":${message['candidate']['sdpMLineIndex']}'
      '}' : 'null'}}';
}

In production, use a JSON serialization library rather than manual string concatenation. The pattern above is simplified for clarity.

Signaling Server with WebSocket

The signaling server is intentionally simple—it relays messages between connected peers without inspecting or modifying them. Here’s a minimal Node.js implementation:

// server.js
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });

const rooms = new Map();

wss.on('connection', (ws, req) => {
  const url = new URL(req.url, 'http://localhost');
  const roomId = url.searchParams.get('room');
  const peerId = url.searchParams.get('peer');

  if (!rooms.has(roomId)) rooms.set(roomId, new Set());
  rooms.get(roomId).add(ws);

  ws.on('message', (data) => {
    const message = JSON.parse(data);
    rooms.get(roomId).forEach((client) => {
      if (client !== ws && client.readyState === WebSocket.OPEN) {
        client.send(JSON.stringify(message));
      }
    });
  });

  ws.on('close', () => {
    rooms.get(roomId)?.delete(ws);
    if (rooms.get(roomId)?.size === 0) rooms.delete(roomId);
  });
});

Each peer connects to the signaling server with a room ID. Messages (offers, answers, ICE candidates) are broadcast to all other peers in the same room. For a two-person call, this is straightforward. For group calls, consider using a mesh topology for up to 4-5 participants or switching to an SFU (Selective Forwarding Unit) like mediasoup for larger groups.

Handling NAT Traversal with ICE

NAT traversal is the hardest part of WebRTC. Most mobile devices sit behind carrier-grade NAT, which blocks incoming connections. ICE solves this by gathering candidates from multiple network interfaces and connectivity paths:

  1. Host candidates — Local network addresses (useless behind NAT)
  2. Server-reflexive (srflx) candidates — Discovered via STUN; the public IP/port as seen by the STUN server
  3. Relay candidates — Allocated through TURN; traffic is proxied through the TURN server

The ICE agent checks connectivity between candidate pairs and selects the best path. In order of preference:

host > srflx > relay

For production mobile apps, always deploy your own STUN/TURN infrastructure. Google’s public STUN server is rate-limited and unsuitable for high-traffic applications. Use Coturn for self-hosted TURN servers, or services like Twilio’s Network Traversal Service for managed infrastructure.

The ICE connection state progresses through these phases:

new -> checking -> connected -> completed

If connectivity checks fail, ICE falls back to relay candidates. Monitor the onConnectionState callback to detect failures and reconnect.

Rendering Video in Flutter

The RTCVideoView widget handles video rendering with minimal configuration. It automatically manages scaling, mirroring, and frame updates.

Widget buildVideoCallUI() {
  return Stack(
    children: [
      SizedBox.expand(
        child: RTCVideoView(
          _remoteRenderer,
          objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
        ),
      ),
      Positioned(
        right: 16,
        top: MediaQuery.of(context).padding.top + 16,
        width: 120,
        height: 160,
        child: ClipRRect(
          borderRadius: BorderRadius.circular(8),
          child: RTCVideoView(
            _localRenderer,
            objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,
            mirror: true,
          ),
        ),
      ),
    ],
  );
}

For production UIs, add controls (mute, camera flip, end call) with animated transitions. Consider using ValueListenableBuilder on the connection state to show connection quality indicators.

Optimizing for Mobile Networks

Mobile networks are unpredictable. WebRTC adapts automatically, but you can tune the behavior:

final constraints = {
  'offerToReceiveAudio': true,
  'offerToReceiveVideo': true,
};

// Set preferred video constraints
final videoConstraints = {
  'width': {'ideal': 640},
  'height': {'ideal': 480},
  'frameRate': {'ideal': 30},
};

_peerConnection!.addTransceiver(
  track,
  RTCRtpTransceiverInit(
    direction: RTCRtpTransceiverDirection.RecvOnly,
    sendEncodings: [
      RTCRtpEncoding(
        maxBitrate: 500000,
      ),
    ],
  ),
);

For bandwidth-constrained scenarios, limit resolution to 480p and enable adaptive bitrate. Monitor RTCRtpSender parameters to adjust quality dynamically based on network conditions. Enable DTX (Discontinuous Transmission) for audio to reduce bandwidth when the user isn’t speaking.

Cleanup and Resource Management

WebRTC connections consume significant resources. Always dispose of them properly to prevent memory leaks and orphaned connections:

Future<void> hangUp() async {
  await _localStream?.dispose();
  await _peerConnection?.dispose();
  _channel?.sink.close();
  await _localRenderer.dispose();
  await _remoteRenderer.dispose();
}

Call hangUp() when the user leaves the call screen, when the app goes to background (if the call should end), or when the signaling server disconnects unexpectedly.

Conclusion and Next Steps

WebRTC gives you direct, low-latency media streaming between mobile devices. You’ve built the core infrastructure: peer connections, SDP negotiation, ICE candidate handling, signaling over WebSocket, and video rendering. Key takeaways:

  • WebRTC is transport-agnostic — the signaling server is a thin relay, not a media server
  • ICE handles NAT traversal — deploy STUN/TURN for production reliability
  • Mobile networks need tuning — constrain resolution and bitrate for cellular connections
  • Resource cleanup matters — always dispose of connections and streams

Next steps: add end-to-end encryption with DTLS-SRTP (enabled by default in WebRTC), implement screen sharing with getDisplayMedia, and integrate with a recording backend for call archiving. For group calls, evaluate an SFU architecture to avoid the O(n²) scaling problem of mesh topologies.

For more details, see the MDN WebRTC API documentation.