Skip to content
All insights
  • Mobile Engineering
  • Flutter
  • Architecture

Cross-platform vs. native mobile: an engineering decision, not a marketing one

The "one codebase" pitch hides where native actually breaks even. The real question isn't which framework to pick, but which subsystems in your specific product force a native escape hatch.

Published

9 min read

The cross-platform pitch is always the same: write one codebase, run it on both platforms. For the interface layer that is largely true. A button, a scrollable list, a form — the framework abstracts these well, and the difference between Android and iOS is rarely something a user notices.

The trouble starts where the app stops merely drawing pixels and starts operating something the operating system owns: the microphone during a call, a screen that must stay awake while the app is backgrounded, a notification that has to ring like a real phone call, a camera that has to deliver a live frame. These are controlled by the OS, not the framework, and the OS's policy around them gets stricter every year. The right question for any team is not "Flutter or native," but: which specific subsystems in our product force a native escape hatch? In a voice-and-video meetings product we built ourselves, the answer was not the same across subsystems — and that unevenness is the actual lesson.

Four places where the OS decides, not the framework

### The audio path during a call

A general-purpose audio-session plugin gets you most of the way: session category and mode on iOS, audio attributes on Android — configurable from Dart, and usually sufficient. But when a more mature native media library sits underneath that plugin (a WebRTC engine, say) and that library also has opinions about audio routing — switching between speaker and earpiece, holding the route steady across a network interruption — you now have two layers that each believe they own the audio path. The fix is not more native code; it is figuring out which layer actually gets to decide, and deferring to it completely. We learned exactly this in our own product: fighting for audio-route control from two places at once produces unpredictable behavior, and the fix was handing that decision entirely to the layer that actually understood the media engine, rather than to a generic OS-level API called from the app side.

### Background execution

This is where genuine, unavoidable native code enters. Android gets stricter every release about what a backgrounded app is allowed to do; keeping a call alive with the screen locked is no longer solved by a generic "keep alive" package — it requires an actual foreground service with a declared type (microphone, camera) written directly in the platform's native language, because the OS's API surface here moves faster than a cross-platform wrapper can track. On iOS, declaring the necessary background modes (audio, VoIP, remote notification) is often enough on its own, with no bespoke native code required — an asymmetry between the two platforms that's worth knowing going in, rather than discovering under a deadline.

### Push and the incoming-call moment

This is the most native-heavy subsystem of all. Turning a push message into a full-screen ringing interface — one that displays over the lock screen, keeps the screen on, and lets actions like declining a call run without opening the app at all — requires bespoke native code, because the OS surfaces for "interrupt the user for an incoming call" (full-screen intents, native call-handling UI) are exactly the kind of surface no cross-platform framework abstracts well. Choosing not to adopt the deepest level of native call integration is itself a legitimate, deliberate decision — it costs you the platform's native answer/decline system UI on that side — but it's a known, bounded cost rather than a hidden limitation, and that's what makes it engineering rather than an accident.

### Camera and media capture

This is often the one subsystem that genuinely stays cross-platform, because a mature native media library sitting underneath the plugin (again, a WebRTC engine) already carries native camera capture with it, and the Flutter binding is a thin layer on top. No bespoke native camera code is needed here — someone else already solved this problem, and you're simply using the solution.

The decision checklist

One last point: this checklist shouldn't run only once, in the big design meeting. A product's sensitive subsystems shift over time — today the riskiest thing might be call ringing; six months from now it might be Bluetooth audio routing or background recording, a capability that wasn't even on the map when you first designed the product. So before picking a framework, ask these questions per sensitive subsystem, not once for the whole app — and run it again every time a new OS-sensitive capability lands on the roadmap:

  • Does a mature native library already sit underneath the cross-platform plugin and solve this problem? If so, you probably don't need an escape hatch.
  • Is this a subsystem where OS policy gets stricter year over year (background execution, battery, privacy)? If so, assume the plugin ecosystem is always a step behind.
  • Does this subsystem require a native OS-level UI surface (lock screen, system call UI, native widgets)? If so, this is where native is genuinely unavoidable.
  • If you decide against the deepest level of native integration, have you written down the cost of that decision explicitly — not as an embarrassing limitation, but as a known trade-off?
  • Have you evaluated your product's sensitive subsystems individually, or made a single "Flutter or native" call for the whole app?

The right answer is almost never "everything cross-platform" or "everything native." It's a map of which subsystem belongs to which layer — and that map cannot be drawn from a framework's marketing slide. It has to be drawn from how the OS actually behaves and how your product actually behaves.

What this decision actually costs

A native escape hatch is not free, even when it's the right call. The moment you write an Android foreground service or a native call-ringing screen, you no longer have one codebase that "one person understands" — you have two code paths, each of which needs to be maintained by someone fluent in that platform's native language, and each of which needs to be tested on a real device of that platform, not a simulator. That means your release cycle stops being "one build for both platforms." A change to call-ringing logic might only touch one platform, but it still has to go through full regression testing on both, because the interaction between the Dart layer and the native layer is exactly where subtle bugs live.

The practical conclusion isn't to avoid the escape hatch — where the OS is the genuine owner of the behavior, avoiding it costs more than accepting it. The conclusion is to budget for that cost from the start: if you know a subsystem needs native code, hire or train for that native skill deliberately, mark it as a separate track in QA, and treat it as an ongoing engineering cost rather than a one-off exception you'll "clean up later." Teams that deny this cost are usually the same teams that discover, a year in, that half of their product's hardest bugs live exactly at the boundary between Dart and native code — the boundary neither team actually took ownership of.

Before committing to a framework, build the riskiest subsystem first

The "Flutter or native" debate usually gets settled too early and entirely on paper — a comparison table, a few blog links, and a decision you'll be living with for months. The better approach is to actually build, before full commitment, the one subsystem you suspect is most likely to demand a native escape hatch — in our product that was the lock-screen incoming-call ring. Not a slide-deep prototype, but something tested on a real device with a real lock screen. If that spike takes two weeks and works, you make the framework decision with real confidence. If it takes a month and is still fragile, you've just discovered something a comparison table would never have shown you: the real cost of the boundary between layers, not its theoretical cost.

For a founder or CTO making this call for the first time, the temptation is to take the "one codebase" pitch as a general rule and hope the exceptions stay rare. Our experience was that the exceptions aren't rare — they're predictable. The same four subsystems above recur in almost any product that deals with hardware and time-sensitive notifications. Listing them early is cheaper than discovering them in the middle of development.

Have something to build?

Tell us what you are working on. We will tell you honestly whether we are the right team for it.