iOS
Swift and SwiftUI apps for iPhone and iPad, designed to Apple conventions and checked against the review, privacy and accessibility rules long before anything is submitted.
SBPO Consulting · Mobile
A shared codebase is a commercial decision with engineering consequences, and both halves deserve an honest account. Done well, one team ships two apps that users cannot tell from native. Done badly, you get an app that is subtly wrong on both platforms and awkward to fix on either. We would rather say which one your project is heading for before it starts.
Where we come in
The case for cross-platform app development is usually made as a saving, and the saving is real — but it is smaller and differently shaped than the pitch implies.
What you genuinely share is everything that has nothing to do with the operating system: data models, business rules, validation, networking, caching, state management, most screen layouts, analytics instrumentation and the majority of the tests. On a typical product that is most of the code, and an even larger share of the code that changes after launch.
What you do not share is everything at the edges. Push notification configuration, background execution, permission flows, deep links, biometric authentication, in-app purchase, home-screen widgets, share sheets and the camera stack all still have to be built, configured and debugged twice, because they belong to two different operating systems that happen to be reachable from one language.
So the honest version of the benefit is not “half the cost”. It is: one team rather than two, one backlog rather than two, one release plan, and features that arrive on both platforms in the same week instead of three months apart. For most companies that second list is worth more than the first, because an Android app that is permanently a release behind is a product problem long before it is a budget one.
We do not have a house framework. The choice turns on your team and your interface, and a firm that answers before asking about either is telling you about itself rather than about your project.
Flutter draws its own widgets rather than using the platform’s. Rendering goes through Impeller, which precompiles its shaders at engine build time — the fix for the shader-compilation stutter that gave earlier Flutter releases their reputation for first-run jank. Impeller is now the only renderer on iOS and the default on modern Android. Drawing everything yourself buys precise control and identical output across platforms, which suits design-led products and heavy custom animation. It costs you Dart as a language you must hire for, and it means platform-standard controls are reimplementations rather than the real thing.
React Native renders actual native views. Its New Architecture — Fabric for rendering, TurboModules for native access, and the JavaScript Interface replacing the old serialised bridge — has been the default since version 0.76, and Hermes is the default engine, compiling JavaScript to bytecode ahead of time so startup does not pay a parse cost. If you already employ React engineers, or you have a web product whose types and business logic could be shared, that adjacency is worth more than any benchmark.
In practice the decision comes down to four questions. Who can you hire where you are? How much of your interface is custom rather than standard platform components? Do you share code or types with a web application? And do maintained bindings already exist for the specific SDKs you must integrate — the payment provider, the mapping vendor, the hardware you talk to over Bluetooth? That last question has settled more framework decisions than any performance argument.
If your product’s differentiator is that it feels exactly right on each platform, but you would still rather not write your business logic twice, Kotlin Multiplatform shares the logic and leaves each interface fully native. It is more work than a single codebase and considerably less than two complete apps. It is the right answer more often than its market share suggests.
It is easier to trust a recommendation if you know what it is not.
Cross-platform is a poor fit when the core of the product sits at the edge of what the operating system allows. Real-time camera processing and augmented reality, low-latency audio, sustained background location, deep health and fitness integration, and products with a wide surface of platform extensions — widgets, watch apps, CarPlay, Android Auto — all have the same shape of problem: the shared layer is doing very little for you while adding a bridge to maintain and debug. Games belong in a game engine, not in either framework.
It is also the wrong answer when your audience is overwhelmingly on one platform. If nine in ten of your users are on iOS, a second codebase is not the problem you have, and you are paying an abstraction tax for a platform you barely serve. Native iOS work or a dedicated Android build will be simpler and better.
And no framework rescues a thin product. Apple’s review guidelines are explicit that an app should offer more than a repackaged website, and section 4.2 is among the most commonly cited grounds for rejection. If the plan is essentially to wrap an existing site, changing the technology does not change the verdict.
Every cross-platform project has a seam, and the quality of the app is largely decided by how deliberately that seam is drawn.
Flutter crosses it with platform channels: asynchronous message passing between Dart and native code, with Pigeon available to generate type-safe bindings rather than hand-writing string-keyed method names. React Native crosses it with native modules built on the JavaScript Interface, which lets JavaScript hold direct references to native objects instead of serialising every call across a bridge.
The mechanics matter less than the discipline. We define the narrowest interface we can in shared code, implement it once in Swift and once in Kotlin, and document both. A wide, chatty bridge is where cross-platform apps go to become slow and unmaintainable; a narrow, typed one is barely visible in a profile.
The other discipline is dependency auditing. A shared codebase makes third-party packages tempting, and a package containing native code is a liability on someone else’s maintenance schedule. Before adopting one we look at when it last shipped, whether it supports current OS versions, what its licence permits, and whether we could realistically fork and maintain it if the author stopped. “There is a package for it” and “there is a maintained package for it” are different claims, and only one of them is a plan.
Users do not identify frameworks. They identify conventions, and they are unforgiving about them.
The back gesture is the obvious case: on Android it is a system-level expectation every screen must honour, and on iOS it is an edge swipe with its own animation and its own feel. Then there are date and time entry, the share sheet, scroll physics and overscroll behaviour, haptics, how a modal dismisses, where a primary action sits, how permission prompts are worded and when they are asked. Typography is a category of its own — Dynamic Type on iOS and the system font scale on Android both mean your layout has to survive text two sizes larger than the design assumed.
Our approach is one design system with a written list of deliberate exceptions, using Material Design 3 and the Apple Human Interface Guidelines as the two grammars. That exceptions list is the valuable artefact, because divergence that is decided is cheap and divergence that is discovered during App Review is not. Where the interaction design is a substantial part of the project we run it as product design work in its own right rather than as a styling pass over shared components.
Most business applications are not limited by their framework. They are limited by network calls, image sizes, over-fetching and doing work on the main thread — problems that are identical in a native app and get blamed on the framework anyway.
Where a shared codebase can genuinely lose ground is narrower than the internet suggests: cold start, because there is an engine or a JavaScript bundle to initialise before the first screen appears; long lists with complex cells, where the cost of reconciling a view hierarchy shows up; heavy image and video manipulation; and animation driven from application code on low-end Android hardware.
All four are measurable, so we measure them. Cold start time, frame timings during scroll and transitions, and memory use on a mid-range device three years old become budgets agreed at the start rather than opinions exchanged at the end. The device that matters here is not the one on the developer’s desk. An app tested only on a current flagship has not been tested.
The shared codebase pays a real dividend in testing, but only if the test strategy respects where sharing stops.
Unit tests over business logic, validation and state transitions run once and cover both apps — that is the dividend, and it is a large one. Widget or component tests over the interface layer also run once. End-to-end tests have to run per platform, on real devices, because that is precisely where the two apps differ.
Manual testing should then be aimed at the seams rather than spread evenly. The shared screens will behave the same on both platforms; that is the whole premise. What needs a human is the native module, the permission dialogue, the purchase flow, the push notification arriving while the app is backgrounded, and the deep link opened from a cold start. Effort spent re-checking identical shared screens twice is effort not spent where the risk actually lives.
Release automation is set up early, before there is anything urgent to ship. fastlane handles code signing, metadata, screenshots and upload to TestFlight and Google Play tracks; hosted runners from Codemagic or EAS Build remove the requirement that someone in the team owns a correctly configured Mac. Version bumping, changelog generation and staged rollout become part of the pipeline rather than a checklist somebody half-remembers.
The point is not elegance. It is that when a crash appears on a Friday afternoon, shipping the fix should be routine rather than an event that depends on one person being at their desk. Once the app is live, attention moves to the store listing itself, which is a discipline of its own — see app store optimisation for how ranking and conversion on the two stores are handled.
Both frameworks can be embedded inside an existing native app, which makes incremental migration possible, and incremental is nearly always right.
A big-bang rewrite quietly discards years of accumulated edge-case handling: the unusual subscription state, the customer who has been on the app since 2019, the manufacturer whose keyboard misbehaves, the retry logic somebody added after a bad week in production. None of it is documented, all of it was earned, and you rediscover it in the wild with real users attached.
So we start with a self-contained leaf screen — a settings page, a help section, something with few dependencies and a small blast radius — prove the integration, the build pipeline and the release process end to end, then move inward. It looks slower on a plan. It is faster in reality, and it can be stopped at any point without having already burned the existing app.
Both frameworks move quickly, and neither supports old versions indefinitely. Deferred upgrades compound: skip enough of them and the eventual jump becomes a project rather than a task, which is how a cross-platform app turns into an unplanned native rewrite.
The mitigations are dull and effective. Keep the dependency count low. Prefer packages you could fork. Pin versions and upgrade on a stated cadence rather than in response to a deadline. And accept that some platform work arrives every year no matter what you built with, because Google Play enforces a rolling target API level requirement and Apple sets its own minimum SDK expectations for submissions. Budget for that as maintenance, not as a surprise.
If you want a view on whether a shared codebase suits what you are building — including the possibility that it does not — tell us what the app has to do and we will give you the reasoning rather than the recommendation.
Scope
Every engagement is scoped in writing before it starts. These are the artefacts that leave our hands and become yours.
A short document setting out why Flutter or React Native was chosen for your product specifically — team skills, interface complexity, the native SDKs you must integrate, and what the decision costs you. If the honest answer is that either would work, it says that too.
Shared application code with the platform-specific projects intact and buildable, plus a README that lets an engineer who has never seen the project produce a signed build for both stores on their first day.
The explicit list of places where the two apps deliberately behave differently — navigation, back gesture, date entry, share behaviour, permission prompts and typography — so divergence is a decision on the record rather than something discovered in review.
Where the shared layer stops, a narrow typed bridge into Swift and Kotlin with both native implementations written, tested and documented. Platform channels are the part of a cross-platform app that is genuinely two pieces of work, and we scope them as such.
Unit tests over the shared logic, component or widget tests over the interface layer, and end-to-end flows executed on real Android and iOS devices in continuous integration rather than on simulators alone.
Signing, versioning, changelog handling and upload to TestFlight and Google Play internal testing, running from your repository so a release does not depend on one person's laptop being configured correctly.
App Store Connect and Google Play Console entries completed — privacy disclosures, data safety declarations, screenshots for the required device classes — and the submission argued through review if a guideline is cited against it.
The dependency inventory, the currently pinned versions, which packages carry native code that will break on a major upgrade, and a stated cadence for staying inside support rather than discovering the problem when a store deadline forces it.
How it runs
The first question is whether a shared codebase suits your product, not which framework to use. We look at what the app does at its edges — sensors, background work, media, platform surfaces such as widgets and watches — because that is what decides the answer. We have told clients to build natively and we will do it again.
Existing team skills, how much of the interface is custom rather than standard platform components, whether logic or types are shared with a web product, the maturity of bindings for the SDKs you must integrate, and who you can realistically hire. The reasoning is written down so it can be argued with rather than inherited.
A single design system with deliberate exceptions, drawn against Material Design 3 and the Apple Human Interface Guidelines. The exceptions are the valuable part: they are the difference between an app that feels native twice and an app that feels imported once.
Native modules are not a later phase. Building them alongside the shared code surfaces the integrations that have no maintained package early, while there is still time to change the plan rather than absorb it as a surprise late on.
Simulators do not reproduce thermal throttling, memory pressure, manufacturer power management or a three-year-old mid-range Android phone. The device matrix is agreed from your analytics or your target market, and it includes hardware nobody in the office would choose to own.
Signing, metadata, screenshots and store upload are automated early, so shipping a fix is a routine action rather than an event. Release automation pays for itself the first time a serious bug appears on a Friday afternoon.
Documentation, accounts in your name, and a stated schedule for framework and dependency upgrades. A cross-platform app that is never upgraded becomes a native rewrite eventually, which is the expensive outcome the shared codebase was supposed to avoid.
Tooling
We pick tools for the problem, not for the résumé. Where a platform is a poor fit we will say so before you have paid for it.
Non-negotiables
These are checkable. Ask us to demonstrate any of them on your own project before you sign anything.
We do not have a house framework we sell into every project. The decision is written down with its reasoning and its costs, and if native or Kotlin Multiplatform is the better answer for your product, that is what we will recommend.
A shared codebase is not a licence to ship one platform's navigation model to the other. Where conventions differ and the difference is visible to users, the app follows the platform rather than the codebase.
Every platform channel and native module is listed, typed and explained, because the bridge is where an unfamiliar engineer will get stuck and where a framework upgrade is most likely to break something.
Performance and stability are measured on low-end and mid-range Android devices as well as current iPhones. An app that has only ever been run on a flagship has not been tested.
The Apple Developer and Google Play accounts are in your organisation's name, signing keys are yours, and the repository is yours. We will not hold a release hostage to a renewal.
Questions
Usually, but by less than the marketing suggests, and the saving is not evenly distributed. Shared logic, screens, networking and tests genuinely are written once. Anything touching the operating system — permissions, push notifications, background execution, in-app purchase, deep links, widgets, camera and Bluetooth — is still built and debugged twice, because those are two different operating systems. The real drivers are how much of your app lives at that edge, how much of the interface is custom, and whether maintained packages already exist for your integrations. We scope against those variables in writing rather than quoting a percentage saving nobody can defend.
For most product categories, no — not in the sense of identifying the framework. What users do notice is convention: a back gesture that behaves oddly, a date picker that is not the one they know, scroll physics that feel heavy, a keyboard that covers the field they are filling in. Those are design and implementation failures rather than framework limits, and they are avoidable if platform differences are specified rather than left to whatever the shared component library does by default. Where users genuinely can tell is at the extremes: sustained high frame-rate animation on low-end hardware, or an interface built entirely from platform-standard controls that a rendering framework has to reimplement.
It depends on your team and your interface, and anyone who answers without asking about either is guessing. Flutter draws its own widgets through the Impeller rendering engine, which gives precise control and consistent output across platforms — good for design-led products and heavy custom animation, at the cost of learning Dart and of not using genuine platform controls. React Native renders actual native views, runs on the Hermes JavaScript engine, and shares language, tooling and often types with a React web codebase — good if you already employ React engineers or have a web product to share logic with. The tie-breakers in practice are hiring, the maturity of bindings for the SDKs you must integrate, and how much of your interface is custom rather than standard.
Yes. Both frameworks provide a documented bridge to native code — platform channels in Flutter, native modules over the JavaScript Interface in React Native — so anything the operating system exposes to a native app is reachable. The practical question is whether a well-maintained package already exists or whether the bridge has to be written. For common needs such as camera, biometrics, Bluetooth Low Energy and in-app purchase it usually exists; for a niche hardware SDK it often does not. We audit that list before committing to an approach, because "there is a package for it" and "there is a package for it that is still maintained and works on current OS versions" are different statements.
Anything whose core value sits at the edge of what the platform allows. Real-time camera and augmented reality processing, low-latency audio, sustained background location, deep health and fitness integration, and products with a wide surface of platform extensions — widgets, watch apps, CarPlay, Android Auto — are all cheaper and better built natively, because in each case the shared layer is doing very little for you while adding a bridge to debug. Games belong in a game engine. And if your audience is overwhelmingly on one platform, a second codebase is not the problem you have. A middle path worth knowing about is Kotlin Multiplatform, which shares business logic while leaving each platform's interface fully native.
Both frameworks release frequently and neither supports old versions indefinitely, so upgrades are a permanent line item rather than a one-off. Most releases are routine; the expensive ones change the underlying architecture, and even then the pain almost always comes from third-party packages containing native code rather than from the framework itself. The mitigations are unglamorous: keep the dependency count low, prefer packages you could realistically fork, and upgrade on a schedule instead of when a store deadline forces it. You will also face platform work every year regardless of framework, because Apple and Google both set minimum SDK and target API requirements on their own timetable.
Yes, and it should almost never be done as a single rewrite. Both Flutter and React Native can be embedded into an existing native app, which means the migration can run screen by screen: start with a self-contained leaf screen, prove the integration, then move inward. A big-bang rewrite quietly discards years of accumulated edge-case handling — the odd payment state, the customer who has been on the app since 2019, the one manufacturer whose keyboard misbehaves — and you rediscover all of it in production. Incremental migration is slower on paper and considerably faster in practice.
Adjacent work
Swift and SwiftUI apps for iPhone and iPad, designed to Apple conventions and checked against the review, privacy and accessibility rules long before anything is submitted.
Kotlin and Jetpack Compose Android apps, built for the real spread of devices and OS versions your users carry, and kept inside Google Play policy long after launch.
Research-led interface and experience design for products people use repeatedly — flows, states, accessibility and a design system engineering can actually build from.
Part of our mobile apps practice.
Mobile
Send us the problem, the constraint and the deadline. You will get a considered reply from someone who would actually do the work — not a templated proposal.