The question comes up in almost every first conversation about a mobile product: React Native or native? It is asked as a technology question, but on a limited budget it is a question about where the money goes. Cross-platform does not halve your cost; it moves the cost into places that are harder to see when you are writing the estimate. The useful exercise is working out which parts of your specific product genuinely share, and which parts will make you write platform code anyway.
The parts of an app that share cleanly are the parts with no opinion about the device: product logic, state management, data access, formatting, translations, navigation structure, and most of the interface. In a typical Expo codebase that layer holds Redux Toolkit with redux-persist, a hosted backend client for data and auth, i18next for translations and a styling layer such as NativeWind. None of it knows which platform it is running on.
In a well-structured codebase only a small minority of files reference Platform.OS, and platform-suffixed files such as .ios.tsx are worth avoiding as a matter of policy. But the forked portion is not proportional to the effort it consumes. Platform-specific files are the slowest and least predictable work in the project, because that is where you stop writing your product and start negotiating with an operating system. So the framework is not about percentages of files but about which negotiations your product requires.
Anything that runs outside your app's own process is native work on both platforms, whatever framework you chose. Home screen widgets are the clearest example. On iOS a widget is a SwiftUI extension target reading from a shared App Group container the app writes to; on Android it is Glance or RemoteViews in Kotlin. React Native cannot render into either: a widget is not your app but a separate process with a strict memory budget and its own lifecycle.
Expo's config plugin mechanism makes this bearable rather than painless. You keep the Swift and Kotlin sources in your own repository and wire them into the generated native projects at prebuild time, so nobody edits an Xcode project by hand and the native targets survive a clean rebuild. But notice what has happened to the team: you now need somebody who can read and debug Swift and Kotlin. That person is a real line in your budget, and pretending otherwise is where cross-platform estimates usually go wrong.
If your product has to do something while it is not on screen, treat that as the highest-risk area of the estimate whatever stack you pick. Take a timer that must fire reliably with the screen off. In the foreground it is a setTimeout and it is trivial. Backgrounded, both operating systems intervene: iOS suspends an app whose background audio session is not actually producing output, freezing the JavaScript thread and every pending timer with it, and Android's Doze mode, plus per-manufacturer power management on top of it, suspends the process unless something holds it at higher priority.
The workarounds are not elegant. On Android the dependable answer is a media playback foreground service, declared with the right foreground service type and runtime permission on Android 14 and later, which holds the process at a priority where JavaScript timers keep firing; an audio library that only configures an audio session is not enough. On iOS you keep the audio session genuinely active rather than emitting digital silence, paired with an ongoing notification. Going native would not remove the problem, because Doze and audio session rules apply equally to Swift and Kotlin apps. What native removes is the JavaScript thread as one more thing that can be frozen, and a layer of indirection while you diagnose it. If background execution, geofencing, health data sync or long-running uploads are core rather than incidental, price that work as its own project.
The old rule was that anything animation heavy belongs in native. Skia has changed that calculation. An animated star field can run as a single Skia canvas: one background quad and one drawAtlas call covering every star, driven by a single Reanimated frame callback on the UI thread, with no per-star views and no timers in the loop. That is structurally what you would write against Metal or a native canvas, expressed in TypeScript and running on both platforms.
The difficulty moves rather than disappears. You are writing GPU code without native GPU tooling: Xcode's frame debugger and Android GPU Inspector show the Skia layer, not your component tree. Performance also becomes bimodal: either the scene collapses into one draw call and it is fine, or you have accidentally created hundreds of views and it is not, and the distance between them is a rewrite, not a tuning session. Games and continuous camera-processing pipelines still lean native. A rich, animated, visually ambitious interface no longer does.
The line item that most often goes wrong is dependencies. A React Native app of any depth carries a long list of native modules, and you inherit the maintenance state of every one. Patching installed packages is normal rather than exceptional: a C++ header that no longer compiles against a newer toolchain, an Android library whose Gradle config targets an obsolete SDK and resolves from a repository that has since disappeared, a Kotlin coroutine whose uncaught exception kills the app process rather than one feature. Each costs a day or two, usually mid-release.
Do not treat the dependency list as free, and do not choose a native module by GitHub stars or the polish of its demo video. Before committing to one for anything load-bearing, open its native source, check when it last built against a current SDK, and check whether more than one person maintains it. The question is not whether you will patch a dependency but how many, and how far down the stack. Budget it as ongoing maintenance rather than a surprise.
Reach for native when the product's core value is itself a platform capability: a real-time camera pipeline, on-device inference with tight latency requirements, CarPlay or Android Auto, a watch app as the primary surface, or sustained high frame rate interaction as in a game. Reach for it when you are shipping one platform with no near-term plan for the second, because the cross-platform saving is almost entirely on that second platform. And reach for it when your team is already strong in Swift or Kotlin, since familiarity beats theory on a short budget.
Cross-platform holds up when you need both platforms and cannot fund two teams, which is the common founder position; when most of the product is screens, data, state and a design system; and when you still expect to change the product quickly because you are learning what it should be, since one codebase means one change rather than two that drift apart.
The mistake worth naming is deciding from an edge case. Founders regularly talk themselves into native over one feature that might matter in year two, but a single widget, assistant shortcut or missing native module does not justify writing the entire product twice. Write the shared app, and write that one piece in Swift and Kotlin when you reach it. That is what config plugins exist for, and it costs far less than a second codebase and a second team to keep in step. None of this produces a verdict on its own; these are the questions to answer before you commit, and the answers move as the product does.
We design and build AI-powered platforms, web applications, and mobile products. Tell us what you are working on.
Get in touch