Skip to content
← Back to blog

I Built the Same App Twice, in React Native and Swift

John Fay John Fay ·
TL;DR: Native was 27× smaller (1.0 MB vs 27 MB), used 57 MB less memory and took 28% less code. But the visible difference wasn't the gauge — it was the tab bar, because SwiftUI picks up each year's iOS design language for free and React Native inherits it whenever its navigation library ships support. None of that decided it. The React Native app ships to two stores; the Swift one ships to one.
swift react-native expo ios mobile

The question

I shipped Needle, an offline GPS speedometer, built with Expo. It does a few things that are more interesting than they sound: it fuses GPS with the accelerometer so the needle moves smoothly between once-a-second fixes, it works out which way your car is pointing without asking you to calibrate anything, and it times your 0–60.

React Native is a trade. Everyone knows that. But “a trade” is not a number, and I had never seen the number for an app I actually wrote.

So I built it again in SwiftUI.

Not a sketch — the whole thing. Same four screens. Same sensor fusion, transcribed constant for constant: the same PCA decay of 0.998, the same 0.15 noise floor, the same ±3 m/s dead-reckoning clamp, the same launch thresholds, the same 1.5-second plausibility floor that throws out impossible 0–60 times. Same 260° gauge geometry. Same five themes.

If the two apps are the same program, then anything you can see or measure between them is the platform.

Size

My first table said 52×. It was wrong, and the reason is worth more than the number.

The React Native build links a fat binary — x86_64 and arm64 — while the Swift build is arm64 only. I was comparing a two-architecture binary against a one-architecture one and calling the difference React Native. Thinning every Mach-O in the bundle to arm64, which is what App Thinning ships to a phone anyway:

React NativeSwift
.app, arm6427 MB1.0 MB
main binary7.5 MB1.0 MB
embedded frameworks17 MB0
JS bundle2.78 MB—
symbols in main binary102,5577,917

27×. The 17 MB of frameworks is React, Hermes and ReactNativeDependencies. None of it is my app. It is the cost of having a JavaScript runtime in the bundle at all, and it does not grow — a far bigger React Native app would not be 27× its native twin, because that 17 MB is fixed.

Memory, both apps live on the same simulated drive: 237 MB against 180 MB. Treat the absolutes as noise — a simulator process carries overhead a phone does not — but the 57 MB delta is the Hermes runtime, the React framework and the JS heap, and that part travels.

And the code: 2,569 lines against 1,861, about 28% less for the same features. Most of the saving is state plumbing. @Observable replaced a context provider, a reducer, and the ref gymnastics that keep 50 Hz sensor samples out of the render path.

The difference I actually noticed

Here is what I did not expect. Put the two apps side by side at 65 mph and the content area is indistinguishable — same gauge, same colours, same type, down to the pixel.

The difference is the tab bar.

iOS 26 introduced a new floating capsule tab bar. SwiftUI’s TabView renders it automatically; I wrote no code for it. The React Native app draws the older flat bar, because that chrome comes from React Navigation rather than from UIKit.

That generalises past this app, and it is the finding I would keep if I had to throw the rest away:

A native app inherits each year’s OS design language for free. A React Native app inherits it when its navigation library ships support.

Every iOS release re-opens that gap until the ecosystem catches up. If looking current matters to you, that is a recurring tax that never shows up in a benchmark.

The part where I was wrong

I wrote the Swift port, then claimed it was numerically identical to the original. That claim deserved a check rather than my confidence, so I wrote one: push 4,105 colours through both implementations and diff all eight output columns, with the reference side importing the React Native sources directly so it cannot drift from what ships.

It failed. The culprit was fitAccent, which keeps a user-chosen accent colour legible as text.

I had ported it from its own doc comment — “clamps lightness into a legible band” — and that comment does not describe the code. The real function is conditional and asymmetric: in light mode it only acts above lightness 62, pulling down to 42; in dark mode only below 30, pushing up to 62. Most accents pass through untouched.

My version clamped everything, at 55/45. So the two apps rendered different colours across a wide range of inputs — the one thing the whole comparison assumed was identical.

I had read that function. I believed my port of it. The parity run took twenty minutes to write and caught what reading could not.

Where the bridge shows

The most interesting component to port was the accent picker — hue, saturation and lightness sliders over gradient tracks. Both versions are hand-rolled; the React Native one deliberately avoids gesture and animation libraries so the project stays inside the Expo Go runtime.

297 lines in React Native, 184 in Swift. But the line count is not the point. The React Native component carries three refs — onChangeRef, maxRef, widthRef — for one reason: PanResponder captures its handlers once, so without them the drag reads stale props forever.

That is not incidental complexity. It is the shape of the bridge showing through into the design of a component that has nothing to do with the bridge. The SwiftUI version mutates @State from a DragGesture and has no equivalent problem, because there is no equivalent boundary.

What I did not measure

Frame rate. Launch time. Energy. Thermals.

I could have produced numbers for all four and they would have been fiction. The iOS Simulator runs the app’s native code on the Mac’s CPU and draws through the Mac’s GPU — an M4 Max, not a phone — and React Native’s JavaScript runs on that same desktop processor. The bridge overhead and JS-thread contention that make or break an app on a phone are present but scaled by hardware nobody has in their pocket.

The simulator also has no accelerometer, so the entire sensor-fusion path — the thing I was most curious about — has never executed against real data in either build.

Those need a device, and I would rather have a gap than a number I do not believe.

So which one

On every axis I could measure, the native build won. Smaller, leaner, less code, and it looks like it belongs on the OS without my help.

And I am going to keep shipping the React Native one.

Because there is a column the benchmarks could not show me, and I only noticed it when I went to set up the Android pipeline: SwiftUI does not run on Android. Swift the language has Android support for libraries, but Apple’s UI frameworks do not exist there. That native build is an iOS app and always will be.

The Expo app ships to both stores from one codebase. Two stores for 27 MB and 57 MB is a price I will pay — not because the native build isn’t better, but because “better” was never the only axis.

The honest version of the React Native trade isn’t worse app, faster to build. On this evidence it’s a measurably heavier app, a year behind on OS design, for twice the reach. That’s a real trade, and now I know what it costs.