WebRTC on Flutter: Call State, CallKit, and Audio Sessions
The Call That Worked in the Demo and Nowhere Else
At Supernova I build the client for Sync, a realtime super app where people voice and video call each other, one to one and in groups. The calling stack is a Flutter client on top of WebRTC. Getting two devices to see and hear each other took an afternoon. I demoed it and assumed calling was mostly done.
It was maybe fifteen percent done.
None of the things that make a call feel like a call are WebRTC: the native incoming call screen, the phone ringing while your app is dead in the background, the audio stepping aside when a real phone call arrives, the camera light going off when you hang up. WebRTC gets two peers exchanging media. The rest is you, the platform, and the OS, all of it on the device.
CallKit and ConnectionService: Look Like a Real Phone
Users expect an incoming call to look like an incoming call. Full screen, ringing, accept and decline, on the lock screen, in the call history afterward.
On iOS that means CallKit. On Android it means ConnectionService, or in practice a full screen notification on a high priority channel, which is what most teams ship. You do not draw this UI. You report a call to the system and the system draws it, so your Flutter widget tree is no longer in charge.
CallKit gives you the native call experience for free: the green and red screen, the call in the phone's recents, Do Not Disturb integration, and the OS handling audio focus. The cost is that you live inside the OS state machine, which has opinions about the order you do things in.
I structured the client around a few events I report to CallKit and receive back:
- reportNewIncomingCall: I tell the OS a call is ringing; it shows the UI.
- performAnswerCallAction: the user tapped accept; the OS tells me, and only then do I start connecting media.
- performEndCallAction: the user tapped decline or end; I tear everything down.
- didActivateAudioSession: the OS tells me the audio session is live and I can start audio.
That last one is the subtle part. You do not start audio when the call connects; you start when CallKit says the session is active. Get that wrong and you get a call with a picture and no sound.
Permissions at the Right Moment
Camera and microphone permissions feel trivial until you watch a new user answer a video call, get a permission dialog while it is connecting, and hang up.
Request permissions at the moment of intent, not on app launch and not at connect time. For an outgoing call I ask for mic, and camera if it is video, when the user taps to start the call, before I show the preview. For an incoming call I ask the moment they accept, since the call might never happen.
On iOS a denied permission is sticky and can only be changed in Settings. The client has to detect that and show a "your microphone is off, open Settings" path instead of connecting a call where the other side hears nothing. I skipped that in the first version, and the bug reports were all variations of "they can't hear me" from users who had tapped Deny weeks earlier and forgotten.
The Audio Session Is the Hard Part
If you are starting WebRTC on iOS, budget real time for AVAudioSession. It is the most finicky part of the feature.
The session has a category and a mode. For a call you want playAndRecord as the category and voiceChat as the mode. That combination tells iOS you are doing two way live audio, so it gives you echo cancellation and picks the right microphone. Leave the category on playback and recording silently doesn't work, or the volume routes to the wrong place, or the earpiece and speaker fight over who's in charge.
Routing took a while too. A voice call should come out the earpiece and follow the user when they tap speaker, plug in headphones, or connect a car. WebRTC and CallKit each want to manage the session, and if you also poke at it from Flutter you have three parties writing to one piece of global state. My rule: CallKit owns activation, the WebRTC layer configures category and mode, and I call overrideOutputAudioPort only when the user taps the speaker button.
Then there are interruptions: a real phone call, Siri, an alarm. iOS fires an interruption notification and deactivates your audio session. You have to listen for the began and ended events, mute on began, and reactivate on ended. The first version of Sync went silent after any interruption because I wasn't reactivating. Users would take a phone call, come back to Sync, and talk to someone who could no longer hear them. Nobody reports "audio didn't resume after an interruption." They report "the call broke," and you work out which interruption it was.
Staying Alive in the Background
A call has to survive the user switching apps to check a message. On iOS that means declaring the voip and audio background modes and keeping the CallKit call active, because an active CallKit call is what tells the OS your process should keep running with audio. On Android it means a foreground service with the right type so the system doesn't reap you.
Get it wrong and the call works in the foreground and dies a few seconds after the user backgrounds the app. You can't reproduce it under the debugger, because the debugger keeps the app alive. I lost an evening to that one.
VoIP Push and the Ten Second Deadline
On iOS an incoming call arrives as a VoIP push through PushKit, a high priority push that can wake a killed app. The server sends it when someone calls you.
PushKit has a hard rule: when you receive a VoIP push you must report an incoming call to CallKit almost immediately, in the same callback, or the OS kills your app and, if you keep doing it, stops delivering VoIP pushes at all. Apple started enforcing this a few iOS versions back. You cannot hit the network first to fetch call details and then decide whether to ring. Report to CallKit first, synchronously, and figure out the details after.
So the client's PushKit handler calls reportNewIncomingCall before anything else. Only then does it connect to the server, fetch who's calling, and update the CallKit UI with the name. The push payload carries a call ID and a display name so the ringing screen isn't blank while the details load. I left a comment in that file in all caps so future me doesn't move the network call up.
Reconnection When the Network Blips
Users walk from wifi to cellular, into an elevator, out of coverage. Partway through a call the WebRTC connection state goes to disconnected, and you have a few seconds before it goes to failed.
The client watches the peer connection's ICE connection state. On disconnected I don't tear down: I show a "Reconnecting…" banner, keep the CallKit call active, and let ICE restart find a new path. Most blips recover in a second or two. If it goes to failed and stays there past a timeout, I end the call cleanly rather than leaving both people on a frozen frame. Never drop a call for a blip, and never pretend a dead call is alive.
Tearing Down Without Leaving the Camera Light On
Ending a call is where every resource you acquired comes due, and if you miss one the symptom is visible.
In one build, after certain call endings, the green camera dot on iOS stayed lit. The camera was off and the call was over, but the OS still thought something held it. A stuck camera indicator on a social app gets you one star reviews about spying. The cause: I was disposing the peer connection without stopping and disposing the individual media tracks and the camera capturer, and not in the right order. Closing the connection doesn't release the hardware. Stop each track, dispose the renderers, close the peer connection, then let the audio session go.
The stuck audio session was the companion bug. If you don't deactivate the session on end, the phone stays in call audio mode, with earpiece routing and lowered media volume, so the user's music is quiet and tinny for the rest of the day. Teardown is now an ordered checklist that runs on every exit path: user hangs up, remote hangs up, call fails, app gets killed. Every path goes through one dispose routine. The order that stuck: stop local tracks and the camera capturer, dispose renderers, close the peer connection, tell CallKit the call ended, release the audio session last.
Key Takeaways
- WebRTC is the small part: connecting two peers is an afternoon; CallKit, audio sessions, push, and teardown are the actual feature.
- Respect the OS state machine: report calls to CallKit and act on its callbacks; start audio when the OS activates the session, not when media connects.
- PushKit is a hard deadline: report the incoming call to CallKit synchronously on the VoIP push, before any network call, or the OS kills you and cuts off future pushes.
- The audio session needs an owner: pick one party to activate it, set category and mode deliberately, and reactivate after interruptions, or calls go silent after a real phone call.
- Teardown is a checklist: stop every track and the capturer, dispose renderers, close the peer connection, then release the audio session, on every exit path, or you leak the camera light and strand the audio route.