Flutter & mobile

Why calling apps are moving to Flutter

A calling app is one of the harder things to port to a shared codebase — a lot of what makes a phone call work lives close to the operating system. Even so, most of a modern calling app turns out to be portable. The part that has to stay native is smaller than people assume, and the reason is a hard deadline, not a technical limitation of Flutter itself.

Most of the app was never the hard part

Screens, navigation, settings, contacts, call history, account management — the vast majority of a calling app's surface area is ordinary UI wired to a state machine. None of that needs to touch the operating system directly, and all of it can live in one Dart codebase shared across iOS and Android. That's most of the engineering effort, and it's the easy call.

The part worth thinking hard about is the roughly 10% that touches call control directly — and specifically, what happens when a call arrives at a phone where the app isn't running.

The cold-start problem

Picture a call arriving at a fully closed app. A push notification wakes the device. On iOS, the system expects the app to report that incoming call to CallKit almost immediately, or it terminates the app for taking too long. Android's equivalent path — a foreground service plus the Telecom framework — is comparably strict.

The Flutter engine is not running yet at that moment. Spinning one up, starting a Dart isolate, and waiting for a platform-channel handshake before the phone can ring is not something that fits inside that budget. So the ringing screen has to be the operating system's own — CallKit on iOS, Telecom on Android — and the Dart side attaches only once a call already exists.

That single constraint decides the whole architecture: incoming-call presentation and push handling stay native on both platforms, because timing rules it out, not because Dart can't express the logic.

What that means for the Dart side

The practical consequence is that call state has to be modelled as something the Dart layer observes, not something it owns the lifecycle of. There's no reliable "call started" event to listen for, because by the time Dart is listening, the call may already be seconds old. Every call screen has to be able to render correctly from a cold snapshot — mid-call, mid-ringing, whatever state it finds when it first subscribes.

Video has a similar, smaller version of the same story. The cleanest path isn't embedding a native camera view inside the Flutter widget tree — that fights the compositor and breaks layering with anything Flutter draws on top. Handing frames across as a GPU texture instead means video behaves like an ordinary widget: it can sit in a grid, under an overlay, in picture-in-picture, without any special-casing.

The trade worth making

None of this is a reason to avoid Flutter for a calling app — it's a reason to be precise about where the native boundary sits. One shared codebase for the 90% that's ordinary UI, a deliberately thin native layer for the part with a hard real-time deadline, and a clear rule that the thin layer never grows just because it's convenient to put something there. That's a smaller, more maintainable surface than two full native apps drifting apart screen by screen.

Talk through your calling app build