Four production mobile apps, two stacks, and the honest conclusion is not a framework name. It is that the stack matters less than who has to keep the app alive after launch.
I shipped Vijaya in Flutter for a diagnostics centre. I shipped Eazyera, the mobile half of Trax, and the Mojo-360 app in Expo and React Native. All four went to real users on real Android phones, mostly mid-range and older. This is what I learned about cross-platform mobile development from doing both rather than reading benchmarks.
Four apps, four different jobs
The apps look similar on a portfolio page and behave nothing alike in production.
What each app actually had to do
Vijaya
Patients book tests, schedule home collection, pay, read reports
Flutter, Android-first, all age groups
Eazyera
Clients book photographers and videographers, three roles in one app
Expo, iOS and Android, chat and push
Trax mobile
Staff and security move stock and clear gate passes across stores
Expo, scanning, live status, weak signal
Mojo-360
Audiences stream video, subscribe, and watch offline
Expo Router, Bunny video, Razorpay, PiP
Only one of those is a media app. Only one is a marketplace. Two are internal tools wearing a consumer coat. The stack question looks different for each, which is why "which is better" was never the right question.
Why Vijaya went Flutter
Vijaya is a companion to an existing web platform and ERP. Patients of every age needed booking, payments, maps for home sample collection, and PDF reports on phones that were often three or four years old.
Flutter earned its place there. The app renders itself, so a form looked and behaved the same on a flagship and on a budget device with a manufacturer skin. Long booking forms, date pickers, and report lists stayed smooth. Firebase and the payment gateway had first-class packages. One codebase, native feel, no surprises about which widget the OEM decided to restyle this year.
What it cost was less visible. Dart is a second language next to a Next.js and Node stack, so the mobile app and the web platform shared an API and nothing else. No shared types, no shared validation, no shared domain helpers. Every model change had to be made twice by hand, and every review needed someone who could read both. When you are a small team, that split is the real bill, not the framework.
Why the next three went Expo
By the time Eazyera, Trax mobile, and Mojo-360 came around, the pattern was clear. Each of those apps sits next to a web admin that owns the same nouns: bookings, transfers, gate passes, subscriptions, roles.
Expo let one team hold one language across all of it. TypeScript on the API, TypeScript on the web admin, TypeScript in the app. NativeWind meant the Tailwind habits carried over. Zustand and React Query worked the same way they did on the web. When "transfer" gained a status, it gained it once in the shared vocabulary and both surfaces followed.
Build and release matter just as much when you are the person doing them. Managed builds and over-the-air updates for JavaScript changes meant a copy fix or a small bug did not need a store round trip. For a solo operator or a two-person team, that is hours a month back.
The pain is real too. Native modules drift, and an SDK upgrade can eat a week when video, secure storage, and push all need to move together. Video was the hardest: player controls, picture-in-picture, background audio, and cached playback in Mojo-360 all sat at the edge where JavaScript meets platform code. Android keyboards and the back button will find every layout assumption you made.
The questions that actually decide it
I stopped comparing feature matrices and started asking five questions.
How I choose now
- The app is the product, not a second surface
- UI consistency across cheap Android devices is a hard requirement
- Heavy custom rendering, canvas, or animation is the core experience
- The client already owns Dart code or Dart people
- The mobile team can be its own team
- A React or Next.js web admin shares the same domain model
- One team must own web, API, and mobile
- Shared types and contracts save more time than native polish
- Frequent small releases matter more than deep platform work
- The roadmap is workflow features, not rendering feats
Notice that none of them is about performance. Both stacks are fast enough for booking, scanning, chat, and streaming. The tiebreakers are ownership, shared contracts, and release cadence.
There is a third answer worth saying out loud: sometimes neither. If a product is content that people read once and never return to, a responsive web app avoids two stores, two review queues, and an update problem you will own forever.
What both stacks made me learn the hard way
The expensive lessons were never about widgets.
Mobile traps that cost me real days
OTP as a single screen
Problem: Codes arrive late, users mash resend, and the timer expires mid-typing.
Better move: Treat login as a flow: resend limits, generous expiry, autofill support, and a human fallback for support.
Offline as a cache flag
Problem: The app looks fine offline and silently loses what the user did.
Better move: Decide per screen: cached, queued, or honestly refused. Then design the sync and conflict story.
Push tokens as state
Problem: Notifications tied to a device, not the user, so reinstalls and shared phones go quiet.
Better move: Keep notification state on the server per user, with tokens as disposable delivery addresses.
Testing on the good phone
Problem: Everything is smooth on the developer's device and janky on a three-year-old budget Android.
Better move: Keep one deliberately slow device in the loop and test on it before every release.
Permissions requested up front
Problem: Camera and location prompts on first launch, denied by reflex, feature dead with no recovery.
Better move: Ask in context, explain why, and build the denied path as a real screen.
Store review as a formality
Problem: Launch dates announced before the first submission has ever been reviewed.
Better move: Submit a throwaway build early to learn the review queue and the policy questions.
Scanning deserves its own note. On Trax, barcode reads happened in dim warehouses with scratched labels and gloved hands. No framework fixes that. Torch control, a big scan target, audible confirmation, and manual entry as a first-class fallback do.
Offline, honestly
"Offline support" is the phrase that quietly costs the most.
In Mojo-360 it meant cached playback so a commute did not kill a video. Reads worked, and anything that changed money or subscription state waited for a network. In Trax it meant the floor kept working on a weak signal: scan and record locally, sync when the connection returned, and never show a gate pass as cleared until the server said so. In Vijaya and Eazyera it mostly meant graceful failure, because a booking that pretends to succeed offline is worse than a clear error.
Three apps, three different meanings. Write down which one you mean before anyone builds a cache.
What I would pick again
Same decisions, second time around
Vijaya
Flutter again. The app is the product, the audience is Android-first, and UI consistency mattered more than sharing code with the web.
Trax and Eazyera
Expo again. Role-based workflows next to a web admin, where shared types and fast releases beat native polish.
Mojo-360
Expo again, with more time budgeted for video. The player and offline layer deserved their own milestone, not a sprint tail.
Content-only ideas
Neither. Responsive web until someone can name what the app does that a browser cannot.
Lock these before the first screen
Decisions to make before writing UI
Product
- Android-first or iOS-first, and which devices you will actually test on
- The one job the app must do when the network is bad
- Which roles share the app and what each one is allowed to do
- Whether push notifications are a feature or a delivery channel for something else
Engineering
- Who maintains this app in year two, and in which language
- How types and API contracts stay in sync with the web admin
- Which native capabilities you need, and whether they exist as maintained packages
- How a copy fix or hotfix reaches users without a store review
If you cannot answer the maintenance question, the framework debate is decoration.
The part that does not change
Both stacks shipped. Both had a week I would rather forget. What separated the smooth projects from the rough ones was never Dart versus TypeScript. It was whether the workflow was clear before the first screen, whether the domain model was shared or duplicated, and whether someone had tested the app on the phone a real user carries.
For the delivery process around all of this, see how I ship from requirements to production. For the product side of the same decisions, read SaaS app development from MVP to production.
Pick the stack your team can still maintain when the launch excitement is gone. That is the whole answer.