Skip to content
Jaime Chapinal
Go back

I rewrote my React Native app in Swift and benchmarked both

Leer en español

tl;dr: I ported my app Ayer from React Native to native Swift and ran both versions through the same automated test on a library of 21,000 photos. The Swift one shows the first photo in 0.58 s instead of 2.17 s, loads days with 7× less CPU and uses half the memory at launch. scrolling was a tie.

React Native on the left, Swift on the right. Same test, same photos.

The app

Ayer shows you the photos you took on this day in previous years. It also has a home screen widget, collages to put two years side by side, and little memory cards to share. It has always been free and private: no account, no ads, and your photos never leave your phone. That didn’t change. It just got a lot faster. You can get it on the App Store.

until now it was an Expo app. It’s basically a thin layer over Apple’s Photos framework, and in React Native every one of those calls goes through JavaScript and a bridge. I wanted to know how much that was costing.

To be fair to React Native: this is the app as I shipped it. Expo 53, React Native 0.79, Hermes, FlashList, expo-image, and the old architecture (newArchEnabled: false). It’s not the best React Native app you could write, it’s the one I had.

How the port was done

I didn’t write the Swift by hand. I asked Claude Code to analyse the React Native app and write a plan, then gave it the whole port as one long task with “done” criteria it could check itself: it builds, the tests pass, every translation exists in the 7 languages, and every screen is screenshotted next to the React Native one.

the port itself was the fast part. The slow part was everything after it: using the app on my own phone, a few rounds of code review, and fixing what I found. Things it got wrong:

For “same app” to mean something, it had to be checkable. So translations are generated from the React Native source instead of retyped, and the bits that pick a “random” photo for the widget and notifications are tested against fixtures produced by running the original TypeScript.

How I measured it

The rule was: same steps, same checks, same photos. If you measure each app with a different tool, you’re comparing tools.

Results

iPhone 17 Pro simulator, 20,926 photos and 120 videos.

React NativeSwift
First launch: allow Photos → first photo2.17 s0.58 s3.7× faster
Launch until responsive1.48 s0.82 s1.8× faster
Launch, peak memory167 MB81 MB2.1× less
”Next day” ×10, CPU time35.8 s5.2 s6.9× less
Tap “Next day” → photos on screen0.8–1.0 s0.35 s~2.5× faster
Photo viewer, 15 photos, CPU time13.7 s4.9 s2.8× less
Scrolling a busy day, CPU time12.4 s13.2 ssame

The CPU column is the one that surprised me. On a phone, CPU time is battery and heat, and that’s where the port paid off the most.

the tie surprised me too. Scrolling through 349 photos is mostly decoding thumbnails, and that happens inside Apple’s Photos framework in both apps. Native doesn’t make everything faster. It removes the app’s own overhead.

So where does React Native spend all that CPU?

I didn’t profile it, so this comes from reading the code. To load a day, the React Native app pages through expo-media-library once per year, and every page crosses the bridge as JSON. Then, on iOS, it calls getAssetInfoAsync once per photo just to know whether it’s a favourite. The Swift version does a single Photos query per day and gets the favourite flag for free.

So part of the gap is React Native and part of it is how my code used it. That’s probably true of most rewrites.

Putting a clock inside the video

Numbers convince engineers, everyone else wants to see it. So I recorded both apps with simctl io recordVideo and put them side by side with a timer each. I thought it’d take an afternoon. the first version was off by more than two seconds.

simctl only writes a frame when the screen changes, recordings can start a second late, and under load the timestamps jitter or even go backwards. So the timers and the picture didn’t agree.

The fix: while recording, a script flips the simulator’s status bar clock between “1:11” and “8:88” at random intervals and logs when it did it. Every flip is a new frame in the video, so I get a sync point roughly every second. No OCR needed: an 8 simply lights up more pixels than a 1. The random gaps mean only one alignment fits, even after a stall, and then every frame gets retimed to the real clock with an ffmpeg setpts expression.

after that, the timers and the picture match to within a frame or two.

The benchmark found bugs in my port

The best part. The big library found problems in the Swift version that my demo library of a few dozen photos never would have:

  1. The first load could get stuck forever. Every Photos change notification restarted it, and during an iCloud sync they never stop. Now changes are batched and the current load gets to finish.
  2. The viewer kept every photo in memory. Swiping through 15 photos peaked at 404 MB, more than the React Native app. Now only the pages next to the visible one keep their image: 177 MB.

Both would have reached users. And both are the kind of bug an agent won’t find on its own: the code was correct, it just didn’t scale.

Caveats

What I’m taking away

If you want to see what “this day, every year” looks like with your own photos, Ayer is on the App Store. Still free, still private, now a lot faster. More about it at chapiware.com/ayer.



Next post
How I automated the marketing for my mobile app with Wikipedia, Playwright and a €0 budget