+1 (415) 843-4662

Video calling in Ionic apps: WebRTC, TURN, CallKit and ConnectionService

Video and voice calling is the feature that most often gets pushed to "phase two" on hybrid projects, usually because the team assumes it needs a native rewrite. It does not. The WebView in a modern Capacitor app speaks WebRTC natively, and the hard parts of a production calling feature are not the video pipeline at all — they are permissions, staying alive when the app is backgrounded, ringing a locked phone, and knowing why a call failed on a customer's network.

This tutorial builds a one-to-one call in an Ionic + Capacitor app: media capture and an RTCPeerConnection, signalling over WebSocket, TURN so calls survive real networks, native call UI via CallKit and Android's ConnectionService, and the telemetry you need to debug it in the field. The snippets are framework-agnostic TypeScript with an Ionic React shell; Angular and Vue differ only in the component wrapper.

1. Permissions and manifests

Media capture in the WebView needs native permission entries plus a runtime grant. In ios/App/App/Info.plist:

<key>NSCameraUsageDescription</key>
<string>Used for video calls with your support agent.</string>
<key>NSMicrophoneUsageDescription</key>
<string>Used for voice and video calls.</string>
<key>UIBackgroundModes</key>
<array><string>voip</string><string>audio</string></array>

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.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CAMERA" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

Android 14+ requires the typed FOREGROUND_SERVICE_* permissions and a matching foregroundServiceType on any service you run during a call; Play review rejects builds that declare a microphone foreground service without a user-visible reason. Request runtime permission through @capacitor/camera and a microphone permission plugin before the first getUserMedia() call so the user sees your explanation, not a bare OS prompt mid-ring.

On Android also enable WebChromeClient media permission handling — the Capacitor Android bridge does this, but a custom webViewClient in an older project may not, which shows up as NotAllowedError from getUserMedia() with no OS prompt at all.

2. Capturing media

export async function getLocalStream(video = true): Promise<MediaStream> {
  return navigator.mediaDevices.getUserMedia({
    audio: {
      echoCancellation: true,
      noiseSuppression: true,
      autoGainControl: true,
    },
    video: video && {
      facingMode: 'user',
      width: { ideal: 1280 },
      height: { ideal: 720 },
      frameRate: { ideal: 30, max: 30 },
    },
  });
}

Ask for 720p, not 1080p. On mid-range Android the encoder is the single biggest battery and thermal cost in the call, and 720p at 30fps looks identical on a phone screen. Let the browser downscale under congestion rather than pinning a high resolution.

3. The peer connection

const config: RTCConfiguration = {
  iceServers: [
    { urls: 'stun:stun.l.google.com:19302' },
    {
      urls: ['turn:turn.example.com:3478', 'turns:turn.example.com:5349?transport=tcp'],
      username: creds.username,     // short-lived, fetched from your API
      credential: creds.password,
    },
  ],
  bundlePolicy: 'max-bundle',
  iceTransportPolicy: 'all',
};

export function createPeer(local: MediaStream, signal: Signalling) {
  const pc = new RTCPeerConnection(config);
  local.getTracks().forEach((t) => pc.addTrack(t, local));

  pc.onicecandidate = (e) => {
    if (e.candidate) signal.send({ type: 'candidate', candidate: e.candidate.toJSON() });
  };
  pc.ontrack = (e) => onRemoteStream(e.streams[0]);
  pc.onconnectionstatechange = () => {
    if (pc.connectionState === 'failed') pc.restartIce();
  };
  return pc;
}

Three things people skip and then debug for a week:

  • TURN is not optional. Roughly one call in five between mobile networks and corporate Wi-Fi cannot be made peer-to-peer. Run coturn or use a hosted TURN service, and include a turns: TCP/443 entry — that is the only candidate that gets through restrictive corporate firewalls.
  • Credentials must be short-lived. Issue time-limited TURN credentials from your API (coturn's REST auth), never hard-code them in the bundle. Hard-coded TURN credentials in a shipped app get scraped and used as an open relay.
  • restartIce() on failure, not a full teardown. Networks flip from Wi-Fi to LTE constantly on mobile; ICE restart recovers in a second or two where a rebuild drops the call.

4. Signalling

Signalling is just a reliable message channel between the two clients: an offer, an answer, and ICE candidates. A WebSocket on your own backend is enough.

async function placeCall(pc: RTCPeerConnection, signal: Signalling) {
  const offer = await pc.createOffer();
  await pc.setLocalDescription(offer);
  signal.send({ type: 'offer', sdp: offer.sdp });
}

signal.on('message', async (msg) => {
  if (msg.type === 'offer') {
    await pc.setRemoteDescription({ type: 'offer', sdp: msg.sdp });
    const answer = await pc.createAnswer();
    await pc.setLocalDescription(answer);
    signal.send({ type: 'answer', sdp: answer.sdp });
  } else if (msg.type === 'answer') {
    await pc.setRemoteDescription({ type: 'answer', sdp: msg.sdp });
  } else if (msg.type === 'candidate') {
    await pc.addIceCandidate(msg.candidate);
  }
});

Buffer remote candidates that arrive before setRemoteDescription() resolves and flush them afterwards — a race that only appears on fast networks and is the classic "works on my desk, fails in demo" bug.

The WebSocket dies when the app is backgrounded on iOS. Do not rely on it for call invitations; that is what push is for (next section). It only needs to be alive while a call is being set up in the foreground.

5. Ringing a locked phone: CallKit and ConnectionService

An incoming call must ring the device even when your app is not running. A data push cannot do that reliably. The platform mechanisms are:

  • iOS: PushKit VoIP push → CallKit. Apple requires that every VoIP push reports an incoming call to CallKit immediately, in the same callback, or the OS kills the app and eventually revokes VoIP push delivery.
  • Android: high-priority FCM message → a foreground service plus a CallStyle notification, or ConnectionService for full telecom integration (call appears in the dialler, integrates with Bluetooth and Do Not Disturb).

Both live on the native side, so this is a Capacitor plugin. The community plugins in this space (@capgo/capacitor-callkit-voip and similar) cover the common path; for anything beyond a basic accept/decline you will write your own — see our tutorial on writing a custom Capacitor plugin.

The bridge your JavaScript needs is small:

import { CallKit } from './plugins/callkit';

await CallKit.register();                       // returns the VoIP push token
CallKit.addListener('callAnswered', async ({ callId }) => {
  await router.push(`/call/${callId}`);         // deep link straight into the call page
  await joinCall(callId);
});
CallKit.addListener('callEnded', () => teardown());

Register the VoIP token with your backend separately from the normal FCM/APNs token — they are different tokens with different lifetimes, and conflating them is the most common cause of "the app rings on some devices only".

6. Keeping the call alive in the background

When the user switches apps mid-call, the WebView keeps running but video encoding is suspended by the OS. Handle it explicitly:

import { App } from '@capacitor/app';

App.addListener('appStateChange', async ({ isActive }) => {
  const videoSender = pc.getSenders().find((s) => s.track?.kind === 'video');
  if (!isActive) {
    if (videoSender?.track) videoSender.track.enabled = false;   // audio continues
    signal.send({ type: 'video-paused' });
  } else {
    if (videoSender?.track) videoSender.track.enabled = true;
    signal.send({ type: 'video-resumed' });
  }
});

Show the remote user a "camera paused" placeholder rather than a frozen frame. On Android, the foreground service with foregroundServiceType="microphone|camera" is what keeps audio flowing; on iOS the voip and audio background modes plus an active CallKit call do the same job.

7. The Ionic call screen

export function CallPage() {
  const localRef = useRef<HTMLVideoElement>(null);
  const remoteRef = useRef<HTMLVideoElement>(null);
  const [state, setState] = useState<'connecting' | 'connected' | 'reconnecting'>('connecting');

  return (
    <IonPage>
      <IonContent fullscreen className="call-page">
        <video ref={remoteRef} autoPlay playsInline className="remote" />
        <video ref={localRef} autoPlay playsInline muted className="local" />
        {state !== 'connected' && (
          <div className="call-status"><IonSpinner /> {state}</div>
        )}
      </IonContent>
      <IonFooter>
        <IonToolbar>
          <IonButtons slot="start">
            <IonButton onClick={toggleMute}><IonIcon icon={mic} /></IonButton>
            <IonButton onClick={switchCamera}><IonIcon icon={cameraReverse} /></IonButton>
          </IonButtons>
          <IonButtons slot="end">
            <IonButton color="danger" onClick={hangUp}><IonIcon icon={call} /></IonButton>
          </IonButtons>
        </IonToolbar>
      </IonFooter>
    </IonPage>
  );
}

playsInline and muted on the local preview are mandatory on iOS — without them iOS either refuses to play inline or feeds your own audio back into the call. Respect the safe-area insets on the footer controls (see edge-to-edge and safe areas); a hang-up button under the gesture bar is a support ticket generator.

Keep the screen awake for the duration with @capacitor-community/keep-awake and release it in your teardown path.

Switching cameras on an existing connection is a track replacement, not a new connection:

async function switchCamera() {
  const next = facing === 'user' ? 'environment' : 'user';
  const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: next } });
  const track = stream.getVideoTracks()[0];
  await pc.getSenders().find((s) => s.track?.kind === 'video')?.replaceTrack(track);
  localRef.current!.srcObject = stream;
  facing = next;
}

8. Telemetry: know why calls fail

getStats() is the only honest source of truth about call quality. Sample it every few seconds and ship a summary with the call record:

setInterval(async () => {
  const stats = await pc.getStats();
  let inbound: any, pair: any;
  stats.forEach((r) => {
    if (r.type === 'inbound-rtp' && r.kind === 'video') inbound = r;
    if (r.type === 'candidate-pair' && r.state === 'succeeded') pair = r;
  });
  report({
    rtt: pair?.currentRoundTripTime,
    relayed: pair?.remoteCandidateId?.includes('relay'),
    packetsLost: inbound?.packetsLost,
    jitter: inbound?.jitter,
    framesPerSecond: inbound?.framesPerSecond,
    freezeCount: inbound?.freezeCount,
  });
}, 5000);

Track three numbers per release: setup success rate (offer sent → connected), median time to first frame, and percentage of calls relayed through TURN. A jump in the relay percentage usually means a TURN misconfiguration or a new corporate customer; a drop in setup success almost always means an expired credential or an OS behaviour change.

9. Compliance and store review

  • Calling features touch the App Store privacy labels and Play Data Safety form — declare audio/video capture and any recording.
  • If you record calls, you need consent in both directions, and in many jurisdictions an audible indicator. Keep recordings server-side.
  • Apple rejects apps that request the voip background mode without using CallKit. If you are shipping voice-only chat inside your app rather than telephony, do not declare it.
  • Test on a real corporate network with a restrictive firewall before launch, not just on office Wi-Fi.

When to buy instead of build

Everything above is roughly two to three weeks of work for one-to-one calls and a great deal more for group calls, where you need an SFU. If you need more than two participants, recording, or PSTN dial-in, use a managed platform (LiveKit, Daily, Twilio, Amazon Chime) — the client code stays almost identical, and you keep the CallKit, background and telemetry work from this tutorial.

Building calling into an existing Ionic app, or unsure whether your current architecture can take it? Get in touch — our senior Ionic consultants have shipped WebRTC features in field-service, telehealth and support apps, and a short architecture review usually saves weeks.