This is an English adaptation of an article I originally wrote in Japanese on Zenn, shortened for an international audience. I translated it with AI assistance and reviewed it myself.
On September 10, 2026, Shopify Engineering published Native is now the future of mobile at Shopify. Having invested heavily in React Native since 2020, Shopify announced it is moving back to Swift and Kotlin.
I build and run a sleep audio app called SleepTrip on my own, alongside my day job as a web developer. The iOS and Android apps are built with Expo. This post looks at the same question Shopify answered, from a solo developer's perspective.
The short version
I'm staying on Expo.
Even with Expo, I still have to test on both platforms and watch production. AI makes implementation and testing faster, but problems that only show up in production still take time to observe. If I'm carrying that load alone, I want to minimize the effort of reviewing two implementations of the same spec, and of chasing the differences between them. That is why I choose Expo today.
Here's the experience behind that. Recently, SleepTrip had an App Hang problem on iOS (the app stops responding for a while). Before my first fix, about 6.5% of iOS users who played audio between June 26 and July 18, 2026 had an App Hang recorded on one specific code path inside Expo's audio library.
With Claude Code, I found that code path almost immediately. But my first patch didn't eliminate the hang. Counting the fix, the release, and the time spent watching production, it took about two and a half months from the first fix until I could confirm that this path was no longer being recorded. Some of the work got much faster with AI. Some of it did not get faster at all.
To be fair, Expo has its costs too. I'll go through them below.
What Shopify said (my reading)
Here is my summary of Shopify's post. Coding agents have made implementation, testing, review, and keeping feature parity across two platforms much cheaper, so building two native apps is no longer the decisive cost it was in 2020. Given that, Shopify chose native for direct access to platform features and official tooling, and for fewer framework and dependency layers.
What stood out to me was how much they invested in verification to support that decision. Their system for rebuilding React Native code as native code, called Helix, splits agent work into small checkpoints. Each checkpoint has to pass tests, match the running app in a visual review, survive two adversarial code reviewers, and get human approval before moving on. They also named slow simulator-based verification as one of the bottlenecks, and restructured their apps so UI-independent logic can be checked quickly from the command line.
My setup
| Area | Tech |
|---|---|
| iOS / Android app | Expo (React Native) |
| Web (landing page, FAQ, legal pages, creator dashboard) | Next.js |
| API | tRPC on Next.js |
| Database / storage / auth | Supabase, Cloudflare R2, Clerk |
| Repository | pnpm + Turborepo monorepo |
The tRPC types and design tokens are shared between mobile and web.
The team is me plus AI agents. For the first two-plus years, I wrote all the code myself. I started using AI agents around February 2026, and since May I've been handing more work to Claude Code: implementation, code review with a dedicated sub-agent, commits and PRs, the flow from version bump through E2E tests to merge (using Maestro), and data investigation through MCP connections to Supabase, PostHog, RevenueCat, and Sentry.
Two things I decided from the start never to let the agent do: submitting to the stores, and shipping OTA updates to production.
What's left for a solo developer is verification
When I bump a version, the agent runs the Maestro E2E tests automatically. A lot of the "tap through the screens and check that it works" work is now done by machines. AI has clearly lowered the cost of checking, too.
But before anything reaches users, I check it myself. Passing E2E tests and deciding that something is fine to ship are two different things.
In that sense I agree with Shopify: a human makes the final call. The difference is what sits in front of that call. My reading is that Shopify can afford two implementations partly because it built a verification system around the agents' output. A solo developer could build something similar, but that would be a large project of its own. In my situation, my call is that sharing one implementation, and so cutting down on code review and on chasing differences between two implementations, is the lighter load.
A concrete example: SleepTrip has a feature called "Tonight's Journey" that moves from a story into ambient sound when the story ends. Switching the audio source when the story finishes, recovering when the switch fails, saving playback state, and handing off to the feedback prompt the next morning all use the same TypeScript code on iOS and Android. When I change this behavior, I fix and review the shared part once. I still test on both platforms, but I don't have to apply the same spec to two implementations and keep tracking the gap between them.
You can share a server API with native apps too. What matters for me is that the on-device logic, like switching and recovery, is shared as well.
If I moved to native and implemented that shared logic in both Swift and Kotlin, I'd be maintaining the same spec in two places. Claude Code could probably write one from the other. But I would still be the one deciding whether both behave according to the spec.
The costs I've paid with Expo
The heaviest problems were in expo-audio, the library behind the core feature of my app.
An upstream fix let me drop a patch
On iOS, updating the lock screen's now-playing info (title and artwork) in quick succession crashed the app. The cause was inside expo-audio.
I first worked around it in app code, then moved the workaround into a patch on expo-audio in March 2026. I built a minimal reproduction, filed an issue, and opened a fix PR in April. It was merged.
- Reproduction: https://github.com/kotadd/expo-audio-lockscreen-artwork-race-repro
- PR: https://github.com/expo/expo/pull/44498
A merged fix doesn't help you until it ships in a release published to npm, so I kept the patch until I upgraded to Expo SDK 56 in May 2026. Then I removed it, about two months after creating it.
Sending a fix upstream is real work: a reproduction, an issue, a PR, and review. I could have just kept the patch. I sent it upstream because every patch has to be re-checked on every library upgrade, and that cost grows the longer you keep it. The upstream fix was an investment in not maintaining that patch.
Patching expo-audio to stop the App Hang
This is the App Hang from the beginning. It never reproduced on my own devices.
During its periodic playback updates, expo-audio calls AVPlayerItem.currentDate() to calculate the live-stream offset. This asks AVFoundation for the date that corresponds to the current playback position. In the hang traces I investigated, that call was blocking the main thread while it waited on a synchronous request to a system process. My first patch in July 2026 narrowed when this call is made, but the hang still happened in situations my conditions didn't cover. When I reviewed three weeks of Sentry data (August 14 to September 4), this path was still being recorded repeatedly for multiple users, including on version 1.15.0, which had the first patch.
In September, my second patch removed the call entirely: the live-stream offset now always comes back empty (nil). The periodic playback updates themselves keep running. SleepTrip doesn't play live streams, so behavior doesn't change for my app. It would change for apps that do play live streams, which is why this patch can't go upstream as-is. I still carry it as an app-specific patch. As of early October 2026, in the three weeks since version 1.16.0 shipped on September 12, Sentry has recorded no App Hang on this path from that version.
The same pattern, a blocking AVFoundation call on the main thread, still exists elsewhere in expo-audio, and I've seen one hang there.
Finding the cause was quick: I gave Claude Code the stack trace and the library name. What took time was gathering evidence that the patch had worked. The problem didn't reproduce locally, so the only way to know was to ship and watch production for weeks. I only learned that my first patch hadn't covered every case because the hang came back in production.
This would be true with native code too: AI can produce a fix, but confirming it works in production still takes time. Choosing Expo adds the cost of owning bugs in the library layer. For now, I think the benefit of shared code is larger.
Xcode 27: crash on launch
After I started building my Expo SDK 57 app with Xcode 27, it crashed right after launch on real iOS devices. The iOS 27 SDK requires UIScene lifecycle support, and the app startup code generated by Expo still used the old approach. Xcode's own templates have used UIScene since Xcode 11 (iOS 13), so a recently created native app most likely wouldn't have hit this. Expo has an issue for it: on SDK 57 you can enable enableSceneSupport, and SDK 58 is planned to support it by default.
I caught it in a local build before release, so no users were affected. Expo shipped the option quickly, and I had already done the same change in another personal project, so I ported it in less than a day.
One catch: the option needed expo-build-properties 57.0.20 or later and expo 57.0.23 or later. The diff I ported from didn't show that second requirement, because that project was already on expo 57.0.23. Expo's quick response kept this small, but an extra layer brings hidden requirements like this.
Native-layer bugs can't be fixed with OTA
I use EAS Update for over-the-air updates of the JavaScript layer. All three issues above were in the native layer, though, so they required native rebuilds that a JavaScript-only OTA update couldn't replace. Fixing the two audio issues in already-released apps also required store updates.
React Native and library behavior
- Android background: In SleepTrip's React Native setup, JavaScript timers stop while the app is in the background on Android. The tRPC batching path relied on a timer, so requests on that path were never sent while users were asleep. Nothing threw; the requests just sat waiting, so monitoring showed nothing.
- Notification timestamps: In expo-notifications, the timestamp I received when a notification was tapped was in seconds on iOS and milliseconds on Android. I noticed this during development, when my "is this notification new?" check marked every iOS notification as old. One API for both platforms doesn't guarantee the same values.
Costs that remain whatever you choose
One codebase still means two apps. I test on both platforms, deal with store reviews, and deal with store APIs. None of that goes away with native or with Expo.
When would I move to native?
I think about this in two stages, because Expo lets me write native code only where I need it.
Writing parts in native, inside Expo, when:
- a specific task doesn't perform well enough,
- I need an OS feature that existing Expo or React Native libraries don't cover, or
- maintaining patches on a core library costs more than maintaining that part myself.
Audio playback is getting close to the third one. Both of the heavy issues in this post came from expo-audio, and for most of the time since March 2026, I've had some patch on it.
Reconsidering a full move to native, when:
- Verification is automated enough that two implementations don't increase the burden of my final review. If machines can reliably find differences between two implementations, native becomes an option.
- The native parts grow until maintaining the Expo layer costs more than code sharing saves.
-
A library my core feature depends on stops being maintained, and neither alternatives nor partial native code can cover it. Alongside its move, Shopify announced changes to the libraries it maintains:
- React Native Skia: Shopify will sponsor it through the end of 2026. William Candillon plans to fork it in the coming months and continue the work.
- FlashList: Shopify will keep fixing critical compatibility issues while discussing long-term stewardship with other companies.
- Restyle: Shopify will keep it working through the end of 2026, then archive it.
SleepTrip doesn't use these libraries, but fewer companies supporting the React Native ecosystem is a risk I'll keep watching.
Right now, none of these apply to SleepTrip.
Wrapping up
I think Shopify's decision fits Shopify, a company that can invest heavily in verification. My situation is different: when I'm the only human verifying, I want to maintain one implementation of the shared behavior as much as I can.
Expo doesn't free you from library bugs. AI made finding causes faster, but confirming that a patch works, and sending fixes upstream, still take time. If you choose cross-platform as a solo developer, I think you should go in expecting to take that on.
SleepTrip (the app is in Japanese): https://sleeptrip.app/
Top comments (3)
Good to read this from the other side. In 2025 I led the rewrite of two native apps (Kotlin and Swift, kept at feature parity) into one React Native codebase, so I've lived with both setups. For my current app I took a middle path: the data layer and business rules live in a shared Kotlin Multiplatform module with SQLDelight, and the UI stays native. The on-device logic you describe (switching, recovery, saved state) is the part I'd most want written once, whichever stack draws the screens. Two UIs are far easier to review than two copies of that logic.
Thanks, it's great to hear from someone who has lived with both setups. "Two UIs are far easier to review than two copies of that logic" puts it better than I did.
KMP with native UI is the middle path I'd look at most seriously if I ever moved off Expo, since it keeps that logic written once.
One thing I'm curious about: in my case, that logic is tightly tied to playback state coming from the platform audio layer. In your KMP setup, where does platform-driven state like that live? In the shared module behind an interface, or on each native side?
Honest answer: HelperBook doesn't have anything like your audio layer. Its shared KMP module holds the data and the business rules (SQLDelight records, attendance and settlement rules), so no platform-driven state flows into it.
For your case I'd split it like this. The player stays on each native side and reports what happened (started, ended, failed, interrupted) through an interface into the shared module. The shared side owns the state machine and the decisions: switch to ambient, retry, what to persist. Then it tells the native side what to do next. The part you'd otherwise review twice lives in one place, while the lifecycle-sensitive pieces (audio session, interruptions, lock screen) stay next to the platform APIs. The Media3 playback I built for a hiring app was plain native Android, and that's the layer I'd keep native here too.