From React Native to Flutter mid-project — the honest receipt
Six weeks, three TestFlight betas, six paying merchants. A direct account of rewriting a working React Native app in Flutter in November 2024 — what broke, what it cost, and what I'd still do again.
We had a working React Native app. Three TestFlight betas, eight months of work, six paying merchants on the pilot. In November 2024 I rewrote it in Flutter. Here is why, what it cost, and what I would do again — including the parts I regret.
This is not a "Flutter vs React Native" post. I have read a lot of those and they all share the same flaw: the author is selling you on a decision instead of describing what happened. What follows is what happened in our codebase, with our constraints, on our timeline.
Where we started
Stork is a Turkish shipment-tracking app. The first version was built in React Native with Expo SDK 49 and TypeScript. We picked RN for the reason most teams do. The founding engineer (me) knew JavaScript best, and shipping a single codebase to iOS and Android was the fastest route to a TestFlight beta we could put in front of merchants.
For eight months it worked. The first three TestFlight rounds were stable. We collected feedback from the pilot merchants. The app did what it needed to do.
Then it started breaking.
What started breaking
Three concrete pain points, all native-module related:
The barcode scanner. We used a community camera library to read package codes. The library worked, but every iOS minor update brought a release-note item about deprecated APIs we were using. By October 2024 the scanner had a 200-300ms shutter delay on iPhone 13 and below, and we could not eliminate it. The maintainer was responsive but stretched thin, and the fix was always "wait for the next release."
Push notification routing. We routed notifications differently depending on whether the app was foregrounded, backgrounded, or terminated. The Expo notification library handled the first two cleanly. The third — terminated app, tap a notification, land on the right shipment detail screen — required custom native code on both platforms. Every Expo SDK upgrade made me hold my breath that the deeplink would still land in the right place.
OS-level background sync. We wanted shipment statuses to refresh hourly even when the app was closed. On iOS that meant BGAppRefreshTask. On Android, WorkManager. Both required ejecting from managed Expo and maintaining a custom native module. We deferred it for four months because the eject conversation kept getting raised and shelved.
Each problem on its own was solvable. The aggregate problem was that a meaningful share of every sprint went into yak shaves — module updates, version pinning, "this works in Expo Go but not in a real build" debugging. Most of these took two days. None produced new features.
The week things tipped
The week of October 21, 2024, iOS 18.1 dropped and broke four of our dependencies in five days. Two were a version bump away from working. One needed a fork. The fourth was the barcode camera library, and the fix was "we are aware, working on it." We had merchants depending on the scanner.
That weekend I prototyped the scanner screen in Flutter. Login, scanner, shipment detail — three screens. It took one evening. The Flutter mobile_scanner plugin worked first try, no shutter delay. I closed the laptop and went for a walk. By the time I got home I had decided.
Why Flutter specifically
I want to be specific about the reasoning, because "Flutter is better" is not an argument I would accept from anyone else.
- Single compilation target. Dart compiles to native via AOT. No JS bridge, no Expo dev client, no Hermes-vs-JSC question. What works in development works in production. The number of "but does this work in a release build?" conversations dropped to zero.
- Cupertino and Material widgets in the same toolkit. We wanted the iOS app to feel like iOS and the Android app to feel like Android. Flutter ships both widget families. We did not have to bolt on a UI library.
- Release cadence and ecosystem. This is the unfashionable point. Flutter has fewer packages than RN, and most of them are maintained by the Flutter team or a small, consistent group of contributors. RN's ecosystem has more options but more fragmentation. We did not want options. We wanted boring.
The third point is the one I'd defend the hardest. "More packages" sounds like a benefit until you are picking between three barcode scanners on a Friday night with merchants waiting.
What the migration actually looked like
I gave myself six weeks. It took six weeks.
Week 1 — spike and environment. Install Flutter on my dev machine. Bring up iOS and Android emulators. Port login and home as a vertical slice. I made a list of the nine RN packages that needed Flutter equivalents and ranked them by risk: barcode scanner, push notifications, background sync, secure storage, file picker, image picker, deep links, biometrics, in-app browser. The first three were the ones I worried about.
Weeks 2-4 — rewrite, screen by screen. I went in feature order, not screen order. Authentication first, then shipment list, then shipment detail, then scanner, then settings. I kept the RN app running in parallel and matched the Flutter version pixel-for-pixel where it mattered (CTA placement, list density) and let it drift where it did not (transition curves, splash timing).
// package.json — RN side, the bits that hurt
{
"dependencies": {
"expo": "~49.0.0",
"expo-barcode-scanner": "~12.5.0",
"expo-notifications": "~0.20.0",
"expo-task-manager": "~11.3.0",
"@react-native-community/netinfo": "^9.3.10",
"react-native-reanimated": "~3.3.0",
"react-native-svg": "~13.9.0",
"react-native-mmkv": "^2.10.0"
}
}# pubspec.yaml — Flutter side, the equivalents
dependencies:
flutter:
sdk: flutter
mobile_scanner: ^5.2.3 # 1:1 replacement for expo-barcode-scanner
firebase_messaging: ^15.1.6 # replaces expo-notifications routing
workmanager: ^0.5.2 # replaces expo-task-manager
connectivity_plus: ^6.1.0 # replaces @react-native-community/netinfo
flutter_secure_storage: ^9.2.2 # replaces react-native-mmkvWeek 5 — the hard packages. Background sync and push routing. Push routing turned out easier than I expected because Flutter's firebase_messaging handles terminated-app deeplinks through a single getInitialMessage() call. The RN equivalent needed separate handlers on iOS and Android.
The RN bridge module that broke most often was our custom shipment-sync background task. Roughly:
// RN side — the bridge module that kept breaking
import { NativeModules, NativeEventEmitter } from "react-native";
const { ShipmentSync } = NativeModules;
const emitter = new NativeEventEmitter(ShipmentSync);
// Subscribe to native-side sync events. The problem:
// every Expo SDK upgrade renamed something, and the
// event payload format drifted twice across versions.
emitter.addListener("shipmentSyncDidFinish", (payload) => {
// payload.shipmentIds was sometimes an array,
// sometimes a JSON string. Real code.
const ids = Array.isArray(payload.shipmentIds)
? payload.shipmentIds
: JSON.parse(payload.shipmentIds);
refreshLocalStore(ids);
});The Flutter version was one call to Workmanager().registerPeriodicTask(...) and a top-level callback. No bridge, no payload drift, no event emitter:
// Flutter side — the 1:1 replacement, cleaner
@pragma("vm:entry-point")
void callbackDispatcher() {
Workmanager().executeTask((task, inputData) async {
// Runs in a background isolate. Pulls latest
// shipment statuses, writes to the local DB.
final ids = await syncShipmentsFromServer();
await refreshLocalStore(ids);
return true;
});
}
void main() {
Workmanager().initialize(callbackDispatcher);
Workmanager().registerPeriodicTask(
"shipment-sync",
"syncShipments",
frequency: const Duration(hours: 1),
);
runApp(const StorkApp());
}Week 6 — App Store resubmission. A separate story. Short version: going through review with a Flutter binary took about the same time as going through review with an RN binary. Apple does not care what your toolkit is.
What the migration cost
In hours: about 240 hours of solo work across six weeks. Some of those weeks I was also still maintaining the RN build for pilot merchants, which slowed both tracks.
In opportunity cost: three weeks of new features I could have shipped instead. The merchant pilot had a feature wishlist. We delivered the migration before we delivered any of it.
In ecosystem cost: two of the nine packages I picked are still suboptimal. flutter_inappwebview works but feels overweight for our use case. The Turkish address autocomplete package we use is community-maintained by one person, and that bus factor worries me. I am living with both.
What I'd do again
The migration itself, yes. Specifically:
- I would still pick Flutter for this kind of app — a shipment-tracking UI with a barcode scanner, push routing, and background sync.
- I would still do it solo. Two people on a rewrite generates more coordination overhead than it saves.
- I would still set a six-week budget and not pad it. The budget kept me from over-engineering the rewrite.
What I regret
One specific thing. I should have prototyped the barcode scanner in Flutter on day one of week one, not in week three. It ended up working, but two evenings of tuning the focus and shutter behavior happened in week three when I had less buffer left. If the scanner had not worked, the entire migration would have been the wrong call, and I would have learned that two weeks too late.
The general rule I'd write down for next time: when rewriting an app, prototype the single riskiest native flow in the new toolkit before you commit. Not after.
The boring point
People ask which is better, RN or Flutter, and that is the wrong question. The right question is which is more boring for your specific app. "Boring" here means: which toolkit lets you spend less time debugging the toolkit and more time shipping product.
For Stork, in November 2024, that was Flutter. For a different team — JS-heavy, web-first, sharing logic with a Next.js codebase — it would still be React Native.
Three days after I pushed the Flutter version, a junior on the team looked up and asked: "Wait, the app loads in 1.2 seconds now?" Yeah, I said. It's just Dart.
// while you're here
- 8 min read
React Native, the native driver, and the jank you can finally feel
When useNativeDriver actually buys you something, when it lies to you, and how to find the dropped frame that's making your scanner screen feel slow.
react-nativeperformancemobile - 8 min read
How Apple rejected my app three times for being too Turkish
Eleven days, three rejections, one launch window. A real account of getting the Stork mobile app through App Store review when the reviewer's first complaint was that the language wasn't English.
mobileiosapp-storeinternationalization