text
[!] The following Swift pods are not compatible with ‘React-Core’ because they do not include an umbrella header:
– Folly (from ../node_modules/react-native/third-party-podspecs/Folly.podspec)
– glog (from ../node_modules/react-native/third-party-podspecs/glog.podspec)
/Users/admin/project/ios/Pods/Headers/Public/React-Core/React/RCTBridgeDelegate.h:12:9: error: ‘React/RCTJavaScriptLoader.h’ file not found
Table of Contents
import
^~~~~~~~~~~~~~~~~~~~~~~~~~~~
1 error generated.
** BUILD FAILED **
The following build commands failed:
CompileC /Users/admin/Library/Developer/Xcode/DerivedData/Project-abc/Build/Intermediates.noindex/Pods.build/Debug-iphonesimulator/React-Core.build/Objects-normal/x86_64/RCTBridge.o /Users/admin/project/node_modules/react-native/React/Base/RCTBridge.m normal x86_64 objective-c com.apple.compilers.llvm.objc.1_0.compiler (1 failure)
I’ve been doing this since the iPhone 3G was the "next big thing." I remember when we had to manually manage memory in Objective-C, counting references like a miser counting pennies in a famine. I survived the era of PhoneGap, where we tried to wrap a browser in a native shell and called it an "app." It was a lie then, and it’s mostly a lie now. I’ve got scars from Titanium SDK that still itch when it rains.
But this? This latest disaster wasn't a memory leak or a bad pointer. It was the slow, grinding death of a "Pure Native" dream. We spent three years maintaining two separate codebases—Swift for the shiny iOS crowd and Kotlin for the Android masses. We told ourselves it was for "performance." We told ourselves it was for "the user experience."
The truth is, we were drowning. Every time a designer changed a hex code, we had to coordinate two PRs, two QA cycles, and two different sets of bugs. When the business demanded a new feature, we had to build it twice, usually with the Android version lagging three weeks behind because of some obscure Gradle conflict that only happens on Tuesdays.
So, we burned it down. We moved to **react native**. And before you start lecturing me about "native feel" or "overhead," sit down. I’m tired, I’ve been awake for 36 hours fixing a production crash caused by a botched Hermes bytecode optimization, and I’m going to tell you exactly why the bridge had to burn.
## The CocoaPods Purgatory
If there is a special circle of hell for developers, it’s paved with `Podfile.lock` files. In our old native setup, we thought we were safe. But as the project grew, the dependency graph started looking like a bowl of spaghetti dropped from a ten-story building.
When we first integrated **react native** into our existing native stack (the "brownfield" approach, for those who like fancy terms for "messy"), the iOS build system decided to revolt. You haven't known true despair until you’ve spent six hours trying to figure out why `use_frameworks!` is breaking a C++ library that hasn't been updated since the Obama administration.
```ruby
# The Podfile that broke the camel's back
platform :ios, '13.4'
prepare_react_native_project!
target 'OurFailingApp' do
config = use_native_modules!
# This line right here is a suicide note
use_react_native!(
:path => config[:reactNativePath],
:hermes_enabled => true,
:fabric_enabled => true,
:app_path => "#{Pod::Config.instance.installation_root}/.."
)
target 'OurFailingAppTests' do
inherit! :complete
end
post_install do |installer|
react_native_post_install(
installer,
config[:reactNativePath],
:mac_catalyst_enabled => false
)
# Manual hack because the auto-linker is a liar
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.4'
end
end
end
end
We moved to react native 0.73 because we needed the improved debugging, but the transition was like trying to swap the engine of a plane while it’s crashing. The “CocoaPods Purgatory” isn’t just about the tool; it’s about the fundamental incompatibility between how Apple thinks the world should work and how the modern JavaScript ecosystem actually functions. You’re trying to marry a rigid, static-linked world with a dynamic, “move fast and break things” world. The result is usually a build log that’s 40,000 lines long and ends in a cryptic Exit Code 1.
Android’s Gradle Graveyard
While iOS was failing with header issues, Android was busy being its own brand of nightmare. We hit the 64k method limit back in 2018, and it’s been downhill ever since. People think react native makes Android easier. It doesn’t. It just changes the flavor of the poison.
In our native days, we fought ProGuard rules. Now, we fight the react-native Gradle plugin and the constant friction between the New Architecture and legacy modules. We moved to react native 0.74 recently to get a taste of the stable Yoga layout improvements, but the Android build system treated the update like a foreign body.
// android/app/build.gradle - A monument to our hubris
android {
ndkVersion rootProject.ext.ndkVersion
buildToolsVersion rootProject.ext.buildToolsVersion
compileSdk rootProject.ext.compileSdkVersion
namespace "com.ourfailingapp"
defaultConfig {
applicationId "com.ourfailingapp"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
// If you forget this, the app crashes on 30% of devices
ndk {
abiFilters "armeabi-v7a", "arm64-v8a", "x86", "x86_64"
}
}
packagingOptions {
pickFirst 'lib/x86/libc++_shared.so'
pickFirst 'lib/x86_64/libc++_shared.so'
pickFirst 'lib/armeabi-v7a/libc++_shared.so'
pickFirst 'lib/arm64-v8a/libc++_shared.so'
}
}
The pickFirst directives in the Gradle file are the scars of a thousand “Duplicate File” errors. When you bring react native into a project, you’re bringing in a massive C++ runtime (Hermes), the Yoga layout engine, and a mountain of transitive dependencies. If one of your other native libraries uses a different version of libc++_shared.so, the whole thing blows up. It’s not a “build”; it’s a hostage negotiation.
The Bridge is a Bottleneck (And a Lie)
For years, the “Bridge” was the scapegoat for everything wrong with react native. “Oh, the list is janky? It’s the bridge.” “The animation is dropping frames? It’s the bridge.”
The bridge was a JSON-based asynchronous messaging system. Imagine trying to run a high-speed rail system by sending postcards between the conductor and the engineer. That was the old architecture. You’d send a message from the JavaScript side: “Hey, move this view 5 pixels to the left.” The bridge would serialize that into a string, pass it over to the native side, deserialize it, and then finally update the UI. If you did that 60 times a second while also trying to fetch data from an API, the bridge would get congested.
We saw this firsthand in our old “Hybrid” app. We had a high-frequency event listener on a scroll view. The bridge traffic was so heavy that the UI thread just gave up. It was “jank” personified.
The move to the New Architecture in react native 0.74—specifically the JavaScript Interface (JSI)—was supposed to fix this. JSI allows JavaScript to hold a reference to a C++ host object and invoke methods on it synchronously. No more JSON serialization. No more postcards.
But here’s the kicker: the bridge wasn’t the only problem. The problem was our own logic. We were trying to do too much on the single-threaded JavaScript side. We thought the New Architecture would be a magic wand. It wasn’t. It just exposed how bad our state management was.
Hermes Won’t Save Your Bad Logic
When Facebook (I refuse to call them Meta) released the Hermes engine, they promised faster TTI (Time to Interactive) and smaller APK sizes. And to be fair, they delivered. Hermes pre-compiles JavaScript into bytecode during the build process. This means the engine doesn’t have to parse and compile your code when the app starts.
We saw a 30% improvement in startup time when we switched to Hermes in react native 0.73. But Hermes is a specialized beast. It’s optimized for mobile, which means it cuts corners. It doesn’t have a JIT (Just-In-Time) compiler in the way V8 does. If you’re doing heavy computational work—like processing large arrays or complex math—Hermes might actually be slower than the old JavaScriptCore.
I remember a night last October. We had a report of the app freezing on mid-range Android devices. We blamed Hermes. We blamed the garbage collector. We spent three days profiling the heap.
# npx react-native info
OS: macOS 14.2.1
CPU: (10) arm64 Apple M2 Pro
Binaries:
Node: 20.10.0 - /usr/local/bin/node
Yarn: 1.22.19 - /usr/local/bin/yarn
npm: 10.2.3 - /usr/local/bin/npm
SDKs:
iOS SDK:
Platforms: DriverKit 23.2, iOS 17.2, macOS 14.2, tvOS 17.2, watchOS 10.2
Android SDK:
API Levels: 31, 33, 34
Build Tools: 30.0.3, 33.0.0, 34.0.0
System Images: android-34 | Google Play ARM 64 v8a
IDEs:
Android Studio: 2023.1 AI-231.9392.1.2311.11076708
Xcode: 15.2/15C500b - /usr/bin/xcodebuild
Languages:
Java: 17.0.9 - /usr/bin/javac
npmPackages:
react: 18.2.0 => 18.2.0
react-native: 0.74.1 => 0.74.1
It turned out we were triggering a massive re-render of a 500-item list every time a websocket message came in. We were flooding the Metro bundler with HMR requests during development and then wondering why the production build felt sluggish. Hermes was doing its job; we were just giving it garbage to process.
The lesson? A faster engine just means your bad code runs faster toward a crash. You still have to worry about the main thread. You still have to worry about memory pressure. You still have to be a developer, not just a “coder.”
The JSI Myth vs. Reality
The New Architecture—Fabric (the new renderer) and TurboModules (the new native module system)—is the promised land. It’s supposed to make react native as fast as native.
The reality is that migrating to the New Architecture is like trying to perform heart surgery on yourself while running a marathon. Most of the third-party libraries we rely on—things like react-native-device-info or various camera wrappers—were built for the old bridge. To use Fabric, these libraries need to be rewritten to use C++ TurboModules.
We spent a month just writing “shims” to bridge the gap between our legacy native code and the new JSI-based modules. We had to learn codegen, the tool that generates the C++ boilerplate from TypeScript interfaces.
// Spec file for a TurboModule - This is the "future"
import type { TurboModule } from 'react-native';
import { TurboModuleRegistry } from 'react-native';
export interface Spec extends TurboModule {
readonly getConstants: () => {};
readonly multiply: (a: number, b: number) => Promise<number>;
readonly sendEvent: (name: string) => void;
}
export default TurboModuleRegistry.getEnforced<Spec>('MyLegacyModule');
The codegen process is brittle. If you have a typo in your TypeScript interface, the build fails with a 500-line C++ error that looks like it was written by an angry god. You’re no longer just a JavaScript developer; you’re a build engineer, a C++ wrangler, and a part-time therapist for your IDE.
The JSI isn’t a myth—it is faster. But the “cost of entry” is a massive increase in architectural complexity. You’re trading “jank” for “build-time misery.” For us, it was a trade we were willing to make, but don’t let anyone tell you it’s a “drop-in” improvement.
Yoga and the Flexbox Fever Dream
Let’s talk about UI. In the native world, you have Auto Layout on iOS (which is a constraint-based nightmare) and XML layouts on Android (which are just… old). react native uses Yoga, a C++ implementation of Flexbox.
On paper, it’s great. One layout engine for both platforms. In practice, Yoga is a “close enough” approximation of web Flexbox, but with enough edge cases to make you want to retire.
We had a specific issue with a nested layout on Android. On iOS, it looked perfect. On Android, the text was clipped by exactly 2 pixels. Why? Because of how Android rounds sub-pixel values compared to iOS. We spent two days tweaking lineHeight and padding values.
// The "Fix" that makes me want to quit
const styles = StyleSheet.create({
container: {
flexDirection: 'row',
alignItems: 'center',
// Android-specific hack because Yoga hates us
paddingTop: Platform.OS === 'android' ? 2 : 0,
},
text: {
fontFamily: 'Custom-Bold',
fontSize: 16,
includeFontPadding: false, // Android-only property that nobody knows exists
textAlignVertical: 'center',
}
});
The includeFontPadding property on Android is a classic example of the “leaky abstraction” in react native. You think you’re writing cross-platform code, but you’re actually writing two different apps in the same file, separated by Platform.OS checks.
And don’t get me started on the “Shadow Tree.” Yoga calculates the layout on a background thread, then flattens it and sends it to the native side. If you have a deeply nested component tree, the calculation time adds up. We had to implement react-native-reanimated just to handle simple gestures because the round-trip from the UI thread to the JS thread was too slow for a 120Hz display.
We rebuilt everything in react native because we wanted a single source of truth for our UI logic, but we ended up with a source of truth that had a lot of “if/else” statements for the truth.
Metro Bundler and the 3 AM Coffee
The developer experience (DX) is the one area where react native actually wins, but even that victory is bittersweet. Fast Refresh is a miracle compared to waiting four minutes for an incremental XCode build. Being able to hit Cmd+R and see your changes instantly is what kept us sane during the rebuild.
But Metro, the JavaScript bundler for react native, is a temperamental beast. It’s not Webpack. It’s not Vite. It’s its own thing, and when it fails, it fails spectacularly.
error: bundling failed: Error: Unable to resolve module ../../../assets/images/logo.png from /Users/admin/project/src/components/Header.tsx:
None of these files exist:
* src/assets/images/logo.png(.native|.ios.js|.native.js|.js|.ios.json|.native.json|.json|.ios.ts|.native.ts|.ts|.ios.tsx|.native.tsx|.tsx)
* src/assets/images/logo.png/index(.native|.ios.js|.native.js|.js|.ios.json|.native.json|.json|.ios.ts|.native.ts|.ts|.ios.tsx|.native.tsx|.tsx)
at ModuleResolver.resolveDependency (/Users/admin/project/node_modules/metro/src/node-haste/DependencyGraph/ModuleResolution.js:152:15)
at ResolutionRequest.resolveDependency (/Users/admin/project/node_modules/metro/src/node-haste/DependencyGraph/ResolutionRequest.js:65:18)
You’ll spend half your life clearing the Metro cache. watchman watch-del-all, rm -rf node_modules, yarn install, rm -rf /tmp/metro-cache. It’s a ritual. We’ve automated it into a script called burn-it-down.sh.
The “Descent into Madness” arc ends here, in the cold light of a Tuesday morning, watching a progress bar as Metro bundles 4,000 modules. We rebuilt everything because the native fragmentation was a slow death. react native gave us a way to move faster, to share 90% of our code, and to finally launch features on both platforms at the same time.
But we didn’t “unlock” some magical efficiency. We just traded one set of problems for another. We traded Swift’s strict type system for TypeScript’s “it’s probably fine” approach. We traded XCode’s bloated IDE for a collection of VS Code plugins and terminal windows.
The Post-Mortem Conclusion
Why did we do it? Why did we abandon the “purity” of native code for the chaotic world of react native?
Because at the end of the day, the user doesn’t care if your app is written in Swift, Kotlin, or a series of carefully orchestrated JavaScript calls. They care if the button works. They care if the app crashes.
Our native apps were dying because we couldn’t keep up. The overhead of maintaining two separate teams, two separate architectures, and two separate bug trackers was a weight we couldn’t carry anymore. react native allowed us to consolidate. It allowed us to hire web developers and turn them into mobile developers in a month. It allowed us to fix a critical production bug in 10 minutes with a CodePush update instead of waiting three days for an App Store review.
Is it perfect? No. It’s a mess of C++, JavaScript, and platform-specific hacks. It’s a bridge that’s constantly on fire, but we’ve learned how to run across it without getting burned.
We’re on react native 0.74 now. The New Architecture is mostly stable. Hermes is humming along. The “jank” is gone, replaced by a different kind of technical debt that we at least understand.
If you’re thinking about making the switch, don’t do it because you think it’ll be easy. Do it because you’re tired of fighting a two-front war. Do it because you’d rather deal with a Podfile conflict than a three-week feature lag. Just don’t expect it to be a “seamless” transition. There’s no such thing in mobile dev. There’s only the fire you choose to stand in.
Now, if you’ll excuse me, I have a java.lang.OutOfMemoryError in the MergeDexArchives task to investigate. The bridge might be burning, but the graveyard is always full.
Related Articles
Explore more insights and best practices: