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
CallStylenotification, orConnectionServicefor 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
voipbackground 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.