Native or cross-platform, decided by hardware then hiring

  • Rujen Shakya
  • 9 min read

Most teams settle this by accident. Someone has written Swift before, or the first contractor liked Flutter, and three years of roadmap follow.

Cross-platform is the right default for most mobile apps. Go native when the hardware forces it — a real-time camera pipeline, backgrounded Bluetooth, background audio, or a watch or car surface. Hiring gets a vote only after that question is settled. The rest of this piece is the arithmetic, and you can recompute all of it.

Why cross-platform is the default for mobile apps now

Cross-platform is the default because the two gaps that used to justify native have closed: React Native’s bridge went away, and Flutter stopped stuttering on first run.

React Native’s New Architecture removed the serialisation, not the single JavaScript thread. JSI and the Fabric renderer built on it call into native code directly, but a long render pass still blocks and still drops frames, which is why gesture and animation work moved onto the UI thread with Reanimated years before Fabric landed. It has been the default since 0.76. From 0.82 you can’t turn it off: set newArchEnabled=false on Android or install CocoaPods with RCT_NEW_ARCH_ENABLED=0 on iOS, and the 0.82 release post says both “will be ignored”. The legacy code is still in your binary, though, because that same post says removal “is scheduled to start from the next version of React Native”, which is 0.83.

Flutter never had that problem. It draws every pixel itself rather than using the platform’s widgets, and Impeller replaced Skia’s first-run shader stutter with predictable behaviour. Flutter’s Impeller page scopes it: the only supported renderer on iOS, the default on Android API 29 and above since Flutter 3.27, with a fallback to the legacy OpenGL renderer below that and on devices that don’t support Vulkan. Flutter gives you identical rendering on both platforms; React Native gives you your web front end’s language and types, but not its components.

What still differs between native and cross-platform

What still differs is rate, not capability: the framework boundary blocks almost nothing, and it taxes everything that crosses it once per frame.

VisionCamera’s frame output (frame processors until V5 renamed the hook to useFrameOutput) hands your JavaScript a GPU-backed buffer for each captured frame, and drops frames the moment you stop disposing them fast enough — the VisionCamera docs say that when the buffer pool fills, “the pipeline stalls and subsequent frames will be dropped”. The real cost is the conversion. RGB, which is what most vision models want, costs about 2.6 times the bandwidth of YUV.

Background execution goes wrong the other way. The entry point is always native: a background mode on iOS, a foreground service or a WorkManager job on Android. Android will then run a Dart isolate or a Headless JS task once something native has woken the process. React Native documents Headless JS for Android only, and there is no iOS counterpart.

Binary size rarely decides anything. Measure a release build with flutter build appbundle --analyze-size, listed on Flutter’s app size page, but only Xcode’s app thinning report tells you what a user downloads.

Which mobile features should push you to native?

Four features should push you towards native. Each resists a shared codebase for a specific platform reason rather than a general one, and the table below names the mechanism in each. Three of the four are about when your code runs, not what it can reach, which is why the plugin READMEs make all of them look solved.

Feature Why it resists a shared codebase
Real-time camera pipeline Frames stay on the GPU. Copied into CPU memory and converted, one eats most of a 16ms budget, and on a 120Hz display the budget is 8.3ms.
Bluetooth Low Energy, backgrounded A background-mode declaration, a restore identifier on the central manager, and a willRestoreState handler for a relaunch you did not trigger.
Background audio and calls Audio sessions, interruptions, route changes, foreground services. The plugin is thin; the edge cases are the product.
Widgets, watch faces, car screens Separate targets in the platform’s own UI framework. CarPlay needs an entitlement Apple reviews case by case.

Read it the other way round. Write down the three things your app is for. If none of those rows appears, the decision is already made, and the rest of this article will cost you money to act on. Pick the framework you can staff and spend the difference on the product. We would bill you less for that, and it would still be the better call.

Keep the native module small, because crashes arrive in Swift

Keep the native module small, because writing it isn’t the cost — the cost is that a crash inside it arrives as a native stack trace, and the team you hired to write Dart now has to read Swift and Kotlin.

There will be exceptions. There always are. When one screen needs native you don’t need a native app: put the work behind a module and keep the rest shared. If two or three of those rows are the app, stop: the exceptions are the product, and you’d write the hard half twice in native anyway.

Keep the boundary small. A codegen’d TurboModule spec, or a method channel with a handful of methods. Bulk data crosses as typed bytes, never as a list of numbers. The capture session stays native. If that screen must show what the platform draws, keep it on the texture layer, which is what a plain AndroidView already gives you — Flutter’s Android platform views page grades that path “Good Flutter performance” and warns only that it is “Janky during quick scrolling”. UiKitView has no such option, because iOS platform views are always hybrid composition. And opting into hybrid composition on Android merges the raster thread into the platform thread, which the Android page says “degrades Flutter FPS”.

What a cross-platform mobile team costs against two native teams

A cross-platform team costs $48,000 a year less than two native teams at our published rate, and native-module work has to beat that gap before two teams win.

To price it, this piece uses Ferrystone. Ferrystone is invented — a payments ledger this site uses whenever retries and idempotency come up, now with a phone app that scans a card. No client is described here, and the numbers below were picked so the division comes out legible. Ferrystone’s app is one row of that table, not four.

Our published rate starts at $4,000 a month for a senior engineer at 160 hours, so $4,000 ÷ 160 = $25 an hour. Take the floor, since it makes the cross-platform side look as good as it ever will. One cross-platform engineer is $4,000 × 12 = $48,000 a year, and an iOS engineer plus an Android engineer is $96,000. The gap is $48,000.

Now price the exceptions. Suppose Ferrystone needs two native modules a year, each two weeks on each platform; that input is stipulated, not measured. Four engineer-weeks a module, eight across the year, 8 × 40 = 320 hours, 320 × $25 = $8,000. Divide: $48,000 ÷ $8,000 = 6. The module work has to run six times worse than stipulated before two platform teams are cheaper.

Derive the break-even from capacity instead and it lands in the same place. One engineer-week is 40 × $25 = $1,000, so a $48,000 gap buys 48 engineer-weeks a year: twelve modules, one a month, forever. Derate the estimate fourfold and 32 engineer-weeks costs $32,000 against the same gap, so the decision doesn’t flip. Nor does doubling the module count: four modules a year is $16,000.

The obvious estimate misses the part that moves. A native module isn’t written once; it is re-tested against each operating-system release for the life of the app, and that tail sits outside the four weeks entirely. The quarter a watch app lands on the roadmap you are paying a native engineer anyway: never one team against two, but one team against one and a bit.

Shipping to two app stores from one codebase

Shipping to two app stores from one codebase costs you two reviews per release and an installed base that lags behind both.

Apple comes back with a guideline number against your build. Google can take down the whole developer account, and any account it decides is related to it, which is a different kind of afternoon. The review gap closes quickly. The one that doesn’t is your installed base: a long tail of users who never update. Every API response has to parse on the oldest build you are still willing to serve. What saves you is additive change: new fields, never renamed, never removed.

React Native can ship a JavaScript-only fix through EAS Update without a review — a bug fix, not a feature. Guideline 2.5.2 is the constraint: apps may not “download, install, or execute code which introduces or changes features or functionality of the app…”. CodePush retired with Visual Studio App Center on 31 March 2025. Flutter has Shorebird, which patches your Dart code but not the engine, your native code or your assets, and on iOS the patched code runs in a Dart interpreter rather than AOT. Shorebird’s pricing page lists a free plan to 5,000 patch installs a month and $20 after that.

Symbolication is per build and per platform: dSYMs for iOS, R8 mapping for Android, Dart symbol files or JavaScript source maps above them. Wire it up on day one. The crashes you most want to read sit in your native module and arrive with no framework frames.

Hiring for Flutter and React Native against hiring native engineers

Hiring for Flutter and React Native is easier than hiring senior iOS engineers who have shipped a camera pipeline, at least in the market we hire in. I can’t give you a number for that. What I have is our own shortlists, and anyone quoting a ratio is quoting a job-board scrape.

Hiring only gets a vote once hardware has abstained. Ferrystone’s card scanner is its one row, and its one hard hire. If none of those four rows is what your app is for, hire for the stack you can staff. If one of them is, the hiring problem is the project, and picking the framework you already know is how you end up with a native module nobody can read, six months after the person who wrote it left.

Tell us what’s broken.

Twenty minutes, no deck. We’ll tell you straight whether this is work we should be doing, and if it isn’t, we usually know who should.

We come back within one business day.