
Debug CallKit & VOIP Call Crashes on iOS & Android
VOIP call crashes are the worst kind of mobile failure because they do not happen in your app's normal control flow — they happen in the middle of a system-orchestrated handoff between your process, the telephony stack, and the audio hardware. On iOS that handoff runs through CallKit, PushKit, and AVAudioSession; on Android it runs through the Telecom framework and ConnectionService. When an incoming-call screen crashes, a voip push is not reported in time, or an audio route is stolen mid-call, the user sees a dropped or ghost call and the stack trace rarely points at the real fault. This guide walks through the concrete crash modes in both stacks — the 0x8badf00d watchdog that kills apps which never call reportNewIncomingCall, the delegate-lifetime bugs that make CallKit fire into dangling objects, and the Android call-state races that wedge a Connection between two audio routes — with code you can drop in and a way to instrument the whole call lifecycle.
Why call-lifecycle crashes are different
Three properties separate call bugs from ordinary UI crashes. First, the call is a cross-process contract: your app talks to callservicesd on iOS or Telecom / InCallService on Android, and both sides assume the other is alive at every step. Second, the system enforces hard deadlines — most famously the iOS watchdog that terminates your process if a voip push is not turned into an incoming call within a few seconds. Third, the audio session is a shared, single-owner resource, so a call that works fine in isolation crashes the moment a Bluetooth headset, a second call, or a background audio app competes for the route. If you have already chased audio-route failures outside the call context, our guide to mobile audio and voice crash debugging covers the AVAudioSession and audio-focus fundamentals that this post builds on.
iOS: the CallKit provider lifecycle
CallKit exposes three objects you must manage as a matched set: the CXProvider, which reports calls to the system and receives delegate callbacks; the CXCallController, which you use to request actions like answering or ending a call; and the CXCallObserver, which tells you the current state of every call. The most common crash in this area is a delegate lifetime bug: CXProviderDelegate and CXCallObserverDelegate are held weakly, so a provider created as a local variable, or an observer discarded after setup, silently deallocates and the system calls into freed memory.
final class CallManager {
// Retain these for the lifetime of the call feature — never as locals.
private let provider: CXProvider
private let callObserver: CXCallObserver
private let controller = CXCallController()
init() {
let config = CXProviderConfiguration()
config.supportsVideo = false
config.includesCallsInRecents = true
provider = CXProvider(configuration: config)
callObserver = CXCallObserver()
provider.setDelegate(self, queue: nil)
callObserver.setDelegate(self, queue: nil)
}
}The failure looks like EXC_BAD_ACCESS inside -[CXProvider ...] or a callback that simply never arrives, and it almost always traces back to the provider being a temporary. Create the provider and observer once, store them in properties, and never recreate them per call — recreating a CXProvider mid-call orphans the in-flight transaction and can leave the system believing a call is still active.
Reporting an incoming call
The inbound path is the one Apple polices hardest. A voip push wakes your app in the background, and you must convert it into a CallKit call by calling reportNewIncomingCall(with:update:completion:). If you do anything slow first — a synchronous network fetch, a database migration, a nested dispatch that waits — the system kills you with 0x8badf00d, the "ate bad food" watchdog exception that means your process hung while handling a push. This is the same watchdog we cover in depth in iOS watchdog termination: fix 0x8badf00d crashes.
func handleIncomingVoIPCall(uuid: UUID, handle: String) {
let update = CXCallUpdate()
update.remoteHandle = CXHandle(type: .phoneNumber, value: handle)
update.hasVideo = false
// Report immediately, then do slow work. The watchdog is counting.
provider.reportNewIncomingCall(with: uuid, update: update) { error in
if let error = error {
// Reporting failed: tell the user, then clean up.
self.provider.reportCall(with: uuid, endedAt: Date(), reason: .failed)
}
}
// Heavy setup (signaling, auth) belongs on a background queue, after reporting.
}The deadline is tight — on the order of a few seconds — so the correct pattern is: report first with whatever you already know, then fetch the rest. Reporting a placeholder call and updating it later with reportCall(with:updated:) is always safer than delaying the initial report.
PushKit and the missed-report watchdog
PushKit voip pushes are special: they are delivered even when your app is not running, but they carry a strict contract. Every voip push must be acknowledged by reporting a call, and your app must declare voip in its background modes or the push is simply not delivered at all. A push that arrives while your CXProvider is mid-setup, or a completion handler you forget to call, both produce the same outward symptom: the user taps the notification, the phone rings once, and the app is gone. In the crash log it reads as 0x8badf00d with no obvious stack, because the hang is a missing callback rather than a bad line of code.
The same discipline applies to the voip push payload itself. Never run a full SIP registration or a database write on the push-handling thread — that is exactly the kind of blocking work that exhausts the deadline. If the push handling genuinely needs network, mark the request non-expiring, do the work on a background queue, and call the completion handler the moment you finish. This is a distinct failure surface from ordinary notification debugging, which we cover separately in mobile push notification failure debugging.
Activating audio for the call
Once the call is reported, the audio must actually move to the right device. CallKit asks your app to activate an AVAudioSession in the provider(_:didActivate:) delegate callback, and to deactivate it in provider(_:didDeactivate:). Activating the session from the wrong thread, activating a category that does not match the call (for example .playback instead of .playAndRecord with the voice-chat mode), or deactivating it while another app holds the route produces crashes and silent one-way audio that are indistinguishable from a crash to the user.
func provider(_ provider: CXProvider, didActivate audioSession: AVAudioSession) {
do {
try audioSession.setCategory(.playAndRecord, mode: .voiceChat,
options: [.allowBluetooth, .allowBluetoothHFP])
try audioSession.setActive(true)
} catch {
// Surface this: an unactivated session = a call with no audio.
reportAudioFailure(error)
}
}
func provider(_ provider: CXProvider, didDeactivate audioSession: AVAudioSession) {
try? audioSession.setActive(false, options: .notifyOthersOnDeactivation)
}The .notifyOthersOnDeactivation option matters: it hands the audio route back to whatever was playing before, so a music app or a second call can resume cleanly instead of fighting your session and crashing.
Android: the Telecom connection lifecycle
Android's equivalent lives in the Telecom framework. A self-managed VOIP app implements a ConnectionService that creates Connection objects for each call, registers via TelecomManager, and routes audio through the platform so the system dialer, Bluetooth, and the lock screen all see the call. The crash modes here are about state, not deadlines: a Connection that is torn down while a callback is still in flight, an answer/decline action that races a disconnect, or an audio route set on a connection that is already destroyed.
class VoipConnectionService : ConnectionService() {
override fun onCreateIncomingConnection(
manager: PhoneAccountHandle,
request: ConnectionRequest
): Connection {
val connection = object : Connection() {
override fun onAnswer() {
// Runs on the Telecom binder thread — hand off to your engine.
voipEngine.answer {
setActive()
}
}
override fun onDisconnect() {
voipEngine.hangup {
setDisconnected(DisconnectCause(DisconnectCause.LOCAL))
destroy()
}
}
}
connection.setAudioModeIsVoip(true)
connection.setRinging()
return connection
}
}The single most common Android VOIP crash is calling destroy() or setDisconnected() from the wrong thread, or calling it twice. The Telecom callbacks arrive on a binder thread, not the main thread, so any direct UI or audio work inside them needs to be marshalled; doing it inline janks the call and can trip an ANR. Always make setDisconnected(...) followed by destroy() the final, idempotent step, guarded so it can only run once.
Call audio routing conflicts
Audio routing is where Telecom and the audio stack collide. A Connection can specify an audio route, but the actual device — earpiece, speakerphone, Bluetooth — is managed through AudioManager and AudioDeviceInfo. Setting a route on a connection that is not yet active throws, and switching routes during an active call while another connection holds the audio focus produces the classic "call connected but silent" bug that users file as a crash. Wire the route changes through the connection's active state, and treat every route change as a three-step sequence: pause the call audio, switch the route, resume.
fun switchToSpeaker(connection: Connection) {
if (connection.state != Connection.STATE_ACTIVE) return // never route a dead call
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
audioManager.isSpeakerphoneOn = true
connection.setAudioRoute(connection.audioRoutes
.firstOrNull { it.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER } ?: return)
}Cross-platform race conditions
Both platforms share two structural races that produce most of the remaining "unreproducible" call crashes. The first is a call-state race: a user answers on the lock screen at the same moment your signaling stack reports the call as failed, so the Connection is destroyed while CallKit is still animating the accept. Guard every state transition with the current state, and make transitions idempotent — a second "hang up" for an already-disconnected call should be a no-op, not a crash. The second is an audio-route race: the system changes the route (a Bluetooth headset connects, a wired headset is unplugged) exactly as you call setActive or setAudioRoute. Handle the route-change callback by re-reading the current route rather than trusting the value you cached a moment earlier.
The fix on both platforms is the same discipline: keep a single source of truth for the call's state, make every transition check-and-set, and never perform audio or connection work outside the state machine the platform expects.
Instrumenting the call lifecycle
A call crash report that says "EXC_BAD_ACCESS in CXProvider" or "IllegalStateException in Connection" is nearly useless on its own. Instrument the lifecycle so every failure carries the call's context: the call UUID and direction, whether the incoming call was reported in time, which audio route was active, the Bluetooth devices connected, and the push payload that triggered the wake. Attach these as breadcrumbs before the crash fires, and a silent 0x8badf00d suddenly becomes "incoming call reported 4.2 seconds after the voip push" — the difference between a mystery and a one-line fix.
That is exactly the correlation a purpose-built mobile observability tool gives you. Bugspulse captures call-lifecycle failures with the CallKit/Telecom state and audio-route breadcrumbs intact, so you see whether a call died from a missed report, a dangling delegate, or an audio-route race instead of guessing from a bare stack trace. If you are still on a generic reporter that drops call context, see how Bugspulse handles mobile crash reporting and instrument your first inbound call today.
Call crashes are brutal because they happen at the exact moment the user is watching, and the system deadlines punish every slow or lazy path. Report the incoming call before you do anything slow, retain your providers and connections for the life of the feature, treat every audio-route change as a state transition, and attach call context to every breadcrumb — and the next dropped call will arrive with the whole story attached. Create a free Bugspulse account and ship your next build with the call lifecycle fully instrumented.