Real-Time Calling in React Native: What Happens Behind the Call Button

Oct 31, 2026•17 min read
React NativeWebRTCSocket.IOCallKeepPush NotificationsAndroidiOS

How to read this guide. Everything marked In this implementation describes a real React Native calling app I traced through its source code. Anything marked Recommendation is general engineering advice, not something I'm claiming that app does. I read and documented the code; I did not record device test runs, so nothing here is presented as "tested in production".

Introduction

A call button looks like the simplest thing in an app. You tap it, a phone rings somewhere, and someone answers. The code behind it is not simple at all.

Real-time calling in a mobile app means getting audio and video between two phones, in real time, while also handling a phone that may be locked, an app that may be closed, a network that may drop, and two operating systems with their own rules about how calls should look and behave.

This guide walks through how those pieces fit together in a React Native calling app. You'll learn:

  • which part of the system does which job, and why they are separate,
  • what actually happens for an outgoing and an incoming call,
  • where Android and iOS behave differently,
  • what has to be cleaned up when a call ends, and what can go wrong if it isn't.

It is written for intermediate to experienced React Native developers. You don't need prior WebRTC experience.


Understanding the architecture

At a high level, a call involves five groups of components:

  • the React Native app (UI, call state, WebRTC),
  • a signaling connection (Socket.IO) to the backend,
  • backend services that decide who gets rung and send pushes,
  • push notification infrastructure to wake a phone that isn't actively running the app,
  • native Android and iOS code that shows the system call screen and handles the app being killed.

Figure 1. Signaling (solid lines) goes through the backend. Audio and video (dotted) go directly between the phones.

Two things are worth noticing already:

  1. The backend never carries the conversation. It only helps the phones find each other and agree on how to connect.
  2. Push notifications are a separate path. They exist to wake a phone, not to carry call data.

Technologies and their responsibilities

Each tool in this stack solves one specific problem.

React Native

Owns the user interface (outgoing view, active call view, controls, a small floating view when you leave the call screen), and the call state. In this implementation, a single provider owns the peer connection, the media streams, the call actions (mute, camera, speaker, end) and the cleanup. Call state lives in a mix of Redux, React state, refs and a small external store for the call timer.

WebRTC

Moves the actual audio and video between the two devices. The app uses the react-native-webrtc library. WebRTC handles encoding, encryption and finding a network path between the phones. It does not know how to tell the other phone that a call exists. That is what signaling is for.

Socket.IO

The signaling channel. It carries the small messages that set up and manage a call: "I'm calling you, here's my connection offer", "I answered, here's mine", "here's a network candidate", "I muted", "I hung up". One socket connection is shared across the app, and it also carries non-call events such as chat.

CallKeep

Shows the system's native incoming-call experience. On iOS it works with CallKit. On Android it is used in self-managed mode with the Telecom framework. In this implementation CallKeep is used for incoming calls on both platforms, plus mute sync on iOS. It is not used for outgoing calls, hold or DTMF.

Firebase Cloud Messaging (FCM)

On Android, a high-priority data-only push wakes the app so it can show the incoming call. Data-only matters: with no notification block, the app's own background handler runs and decides what to show.

PushKit (iOS VoIP push)

On iOS, a VoIP push can wake an app even when it's been killed. Apple requires every VoIP push to be reported to CallKit immediately, so the native app delegate does that before JavaScript is even running.

Notifee

Used on Android to display the incoming-call notification: a dedicated high-importance channel, a full-screen action, and Accept and Decline buttons.

Native Android and iOS code

Small native modules handle what JavaScript can't do reliably on its own:

  • Android: show the app over the lock screen when a call is ringing, enter picture-in-picture when you leave the app mid-call, and tell the server the call ended if the app is swiped away.
  • iOS: configure the audio session, keep the camera running in the background for picture-in-picture, register for VoIP pushes, and tell the server the call ended if the app is terminated.

How the calling flow works

1. Starting an outgoing call

When the user taps call, the app navigates to the call screen with the other person's details. From there:

  1. A peer connection is created, configured with STUN and TURN servers.
  2. The app asks for the microphone and camera and adds the tracks to the connection.
  3. It creates an offer, a description of what it wants to send and receive, and sets it as its local description.
  4. The offer is sent to the backend over the socket as a call request. A ringback sound loops on the caller's side.
  5. As the connection discovers possible network routes (ICE candidates), each one is sent through the socket too.

Figure 2. Outgoing call: signaling through the backend, media directly between phones.

Some callers also run a busy check before ringing, a plain HTTP request asking whether the other person is already in a call. In this implementation that check exists on some entry points (appointment flows) but not all of them.

2. Receiving an incoming call

An incoming call can reach the phone in three ways, and they overlap:

  • the socket, if the app is running and connected,
  • an FCM data push (Android),
  • a VoIP push (iOS).

The backend sends the call details over the socket and sends both pushes, because it can't be sure whether the app is alive. A closed app's socket can linger on the server for a short time, so "online" is not always reliable.

On Android, the push handler shows a native call screen through CallKeep and a full-screen notification through Notifee. A small flag, set through a native module and read before JavaScript starts, tells the main activity to appear over the lock screen.

On iOS, the native app delegate receives the VoIP push and immediately reports it to CallKit, even if JavaScript hasn't started.

3. Accepting or rejecting

An important detail: the push does not contain the connection offer. When the user accepts, the app marks that "the next incoming call should be answered automatically", and then waits for the call details to arrive over the socket. When the app connects, the backend replays the pending call it was holding for a short window. That is what lets a killed app answer.

Figure 3. Incoming call from a closed or locked app.

Declining is handled without the main app running: the app opens a short-lived socket with the saved login, sends a "declined" end message, and closes it.

4. Establishing the connection

For the callee, answering means: get the microphone and camera, apply the caller's offer as the remote description, create an answer, set it as the local description, and send it back. Remote candidates that arrive too early are queued until the remote description is set.

5. Managing an active call

During a call the app handles mute, camera on/off, speaker on/off and camera switching. Each toggle changes local state and also tells the other side over the socket so both screens stay in step. A timer ticks once a second. It's kept outside React state on purpose, so only the few text elements that show the duration re-render.

Leaving the call screen doesn't end the call. The call continues in a small floating view, and on both platforms picture-in-picture is used when the app goes to the background.

6. Ending the call

Either side can end it. The ender sends an end message with the call type, status and timing. The server clears its state, notifies both parties and saves a call message into the conversation. The other app receives the end event and tears down its call.

7. Cleaning up

Ending a call runs a single cleanup routine. See the Cleanup section below.


Signaling vs. media

This is the idea that makes the rest click.

SignalingMedia
What it carriesOffer, answer, ICE candidates, mute/camera updates, endAudio and video
TransportSocket.IO through the backendWebRTC directly between the phones
Needs a server?YesOnly STUN/TURN helpers
Failure looks likeCall never starts or never endsCall connects but silence or frozen video

Why separate them? The two phones can't talk directly until they know each other's network details and agree on codecs. They have no way to find each other, so they use the backend as a go-between for that first conversation. Once the connection is set up, sending audio through the server would add delay and cost for no benefit.

What the backend does in this implementation: it routes signaling messages to the right user, swaps the sender and receiver so each side sees the call from its own point of view, keeps a short-lived record of who is in a call, holds a pending call for a short window so a woken app can pick it up, sends the pushes, saves the call message, and ends calls when a participant's connection disappears.

STUN and TURN. STUN helps a phone learn its public address. TURN relays media when a direct path isn't possible. This implementation configures several STUN servers and one TURN server. If you build something similar, don't hard-code TURN credentials in the app bundle. Anyone can extract them.


Android and iOS considerations

TopicAndroidiOS
Incoming triggerFCM data-only pushPushKit VoIP push
Native call UICallKeep (self-managed) + Notifee full-screen notificationCallKit via CallKeep
App killedBackground handler runs from the pushApp delegate reports to CallKit before JavaScript runs
Lock screenNative flag lets the activity show over the lock screenCallKit handles it
In-app ringingAn in-app ringtone and modal may also appearNone. CallKit rings
AnsweringAccept button on notification or call screenAnswer on CallKit, then the app is brought forward
Picture-in-pictureEntered when leaving the app mid-callHandled by the WebRTC view library
Killed mid-callActivity teardown sends an HTTP end requestApp-terminate hook sends an HTTP end request
Audio sessionManaged through the in-call audio libraryConfigured natively at launch

Permissions

On Android, the manifest declares camera, microphone, notifications, foreground-service types (phone call, microphone, camera), Telecom, full-screen intent and Bluetooth connect. Android 14 can block full-screen call screens until the user allows them, so the app shows a one-time prompt that sends them to settings.

Camera and microphone prompts come from the WebRTC library when media is requested. In this implementation there is no separate permission screen before a call, and a denial used to be silent. A message pointing the user to settings was added afterwards.

On iOS, the usage descriptions for camera and microphone must exist, and the background modes for audio, remote notifications and VoIP must be enabled. Push capability must match the environment you ship in.

Platform limitations worth knowing

  • Outgoing calls are not reported to the system call UI on either platform. Only incoming calls use it.
  • Hold, DTMF and call waiting are not implemented.
  • On iOS, the VoIP push can't be "cancelled". Apple requires a VoIP push to show a call, so a cancel has to come through the socket. In this implementation, Android gets a cancel push while iOS relies on the socket.

State management and error handling

Call states

Figure 4. Call states and transitions as they exist in the implementation.

In this implementation, there isn't one call-state object. State is spread across Redux (the call details, the incoming call, whether the user is outside the call screen), React state (streams, whether the call was answered, mute/video/speaker flags), refs (the peer connection, start time) and a small plain object that glues native call events to JavaScript. It works, but it also means "is the call answered?" exists in more than one place. If you build your own, one source of truth is worth the effort.

Handling failures

SituationWhat the implementation does
Permission deniedAlert that points to settings (added after the first audit)
Socket disconnectAutomatic reconnect with a fixed delay. The server ends the call for both sides if the user doesn't return within a few seconds
Remote candidate arrives earlyQueued, then applied once the remote description is set
Push fails or token failsSilent retry on next connection. No user-facing error
App killed mid-callNative code sends an HTTP end request
Call ignoredA notification timeout removes the Android notification. There is no client-side ring timeout
Connection state changes (failed, disconnected)Not monitored. The call is considered active once signaling says it was answered

That last line is the biggest gap. If the media path dies, the UI still shows an active call. Watching the connection state and offering a retry or an ICE restart would be the first thing to add. That's a recommendation, not something the implementation does.

Duplicate or conflicting events

The Android incoming call can arrive through the socket and through the push. The push path is guarded by checking whether an incoming call is already recorded, but the socket path isn't deduplicated. A short-lived flag with a timeout reconciles "user tapped accept" against "the call details haven't arrived yet". It is a time-based heuristic, and worth knowing about if you design something similar.

Resource cleanup

One routine runs for every kind of ending. It:

  1. stops local and remote media tracks,
  2. removes senders and closes the peer connection,
  3. resets the Redux call state,
  4. stops the in-call audio manager and any ringtones,
  5. dismisses the native call UI,
  6. resets the call timer,
  7. tells the native layer the call is over, which clears the stored data used for the killed-app case.

A simplified version of the shape:

// Simplified from the implementation, for illustration
function resetAfterEndCall() {
  localStream?.getTracks().forEach(t => t.stop());
  remoteStream?.getTracks().forEach(t => t.stop());
  peerConnection?.close();
  peerConnection = null;

  resetCallState();
  InCallManager.stop();
  dismissNativeCallUi();
  stopRingtones();
  callTimer.reset();
  nativeModule.setCallActive(false);
}

Common engineering challenges

Incoming calls in the background. Implementation: FCM data push on Android, PushKit on iOS, with native code involved early. Recommendation: assume JavaScript isn't ready when the push lands, and keep anything the user must see immediately in native code or in a handler registered before the app renders.

Keeping call state synchronized. Implementation: mute/camera/speaker toggles are mirrored over the socket; call timing is kept on both client and server. Recommendation: decide who owns each piece of state and avoid storing the same flag in three places.

Managing audio devices. Implementation: speaker on/off through an in-call audio library, re-applied after the remote stream arrives and again a second later because the route can reset. Earpiece/speaker is the only documented route choice. Bluetooth and wired headset selection are not handled explicitly. Recommendation: test with headsets early. Routing bugs only appear on real hardware.

App lifecycle transitions. Implementation: picture-in-picture on background, a floating call view when navigating, a socket reconnect when the app returns to the foreground. Recommendation: every transition (foreground, background, killed, locked) deserves its own manual test.

Avoiding duplicate events. Implementation: listeners are attached only while a call exists and removed on cleanup. Recommendation: add idempotency checks for incoming calls, especially when more than one channel can deliver the same event.

Preventing resource leaks. Implementation: single cleanup routine. Gaps found during review included an old socket not being closed when the login token changed (since fixed), and a media stream that could finish being created after the call had already ended. Recommendation: treat every getUserMedia as something that can resolve late.

Callout: the happy path is the easy part. None of the above shows up in a demo where two phones are on the same Wi-Fi with the app open.


Testing and reliability

What was actually done. The only automated tests for the calling code are two small unit tests for the call timer and time formatting. There are no automated tests for WebRTC, signaling, notifications or native code. During documentation I read the source; I did not record device runs, so every real-world scenario below is "not tested" rather than "passed".

Scenarios worth testing (a suggestion, not a record of results):

ScenarioAndroidiOS
Outgoing audio / videoSuggestedSuggested
Incoming, app in foregroundSuggestedSuggested
Incoming, app in backgroundSuggestedSuggested
Incoming, app killedSuggestedSuggested
Incoming, phone lockedSuggestedSuggested
Accept / decline from the native screenSuggestedSuggested
Mute, camera, speaker, camera switchSuggestedSuggested
Headset or Bluetooth connect / disconnectSuggestedSuggested
Wi-Fi to cellular switch during a callSuggestedSuggested
Airplane mode, then recoverSuggestedSuggested
Remote ends, local ends, app killed mid-callSuggestedSuggested
Call again right after a call endedSuggestedSuggested

Tips.

  • Test on physical devices. Push, lock screen, audio routing and picture-in-picture don't behave the same on simulators.
  • Use two different networks for the two phones at least once, to exercise TURN rather than a local path.
  • Check that production builds still log what you need. This implementation strips console output in release builds, which makes field debugging harder.
  • Verify after ending a call that the camera light goes off, the audio route is restored and the next call starts clean.

Final thoughts

If you're about to build calling into a React Native app, here's what I'd want you to take away:

  1. Separate the three jobs. Signaling sets up and tears down, WebRTC carries the media, push notifications wake the phone.
  2. Plan for JavaScript not being ready. The most important parts of an incoming call happen before your React tree exists.
  3. Android and iOS are different products here. Don't try to make them identical. Make each feel native.
  4. Monitor the connection, not just the signaling. "Answered" and "connected" aren't the same thing.
  5. Write down every way a call can start and end, then make cleanup the same for all of them.
  6. Keep credentials out of the app. Issue TURN credentials from your server.

None of this is exotic, but each piece interacts with the others, and most bugs live in those interactions.