React Native Guide: Build Cross-Platform Mobile Apps Fast

React Native: The High Cost of the “Free” Bridge

I was sitting in a windowless “war room” in 2019, staring at a Grafana dashboard that looked like a heart attack. We were launching a real-time trading feature for a mid-sized fintech app. The JS thread was idling at 15%. The native UI thread was barely sweating. Yet, the app was unusable. Every time a user tapped “Buy,” the screen froze for 400ms. It wasn’t a CPU bottleneck. It wasn’t a memory leak. It was the Bridge.

We were pushing high-frequency WebSocket ticks—JSON payloads containing price updates—across the React Native bridge. Every single tick had to be serialized into a string on the native side, passed across the asynchronous bridge, and deserialized back into a JavaScript object. We were effectively DOSing our own message bus. I spent the next 72 hours rewriting the entire data ingestion layer in C++ using the then-experimental JSI (JavaScript Interface) to bypass the bridge entirely. It worked, but I lost a weekend and gained a permanent distrust of any marketing material that claims “native performance” without showing a flame graph.

The Documentation is Lying to You

If you read the official React Native docs, you’ll see a world of easy components and “hot reloading.” They make it seem like you can build a world-class app without ever touching Xcode or Android Studio. That is a lie. The moment you need to do something non-trivial—like background geolocation, complex image processing, or high-performance Bluetooth Low Energy (BLE) communication—the abstraction leaks. It doesn’t just leak; it bursts.

Most tutorials focus on the “React” part of React Native. They ignore the “Native” part. They don’t tell you that node_modules will eventually break your CocoaPods installation because some library author decided to use a different version of Folly. They don’t mention that your CI/CD pipeline will fail because the ruby version on your GitHub Actions runner is 2.7.4 but your Gemfile demands 3.1.0. This isn’t just “development friction.” This is the reality of managing a polyglot codebase where three different build systems (Gradle, CocoaPods, and Metro) are constantly trying to kill each other.

The Architecture: Why the Bridge is a Bottleneck

To understand why React Native feels “janky” sometimes, you have to understand the old architecture. It’s built on three pillars:

  • The JavaScript Thread: Where your business logic lives. It runs the Hermes engine (or JSC on older versions).
  • The Native Thread (Main Thread): Where the UI is rendered. It handles touches and draws pixels.
  • The Shadow Thread: Where the Yoga layout engine calculates the positions of your elements before sending them to the native side.

Communication between these threads happens over the Bridge. Think of the Bridge as a JSON-based message queue. It’s asynchronous. It’s batched. And it’s slow. If you send a message from JS to Native saying “Change the background color to red,” that message is queued. If the Bridge is busy with a 2MB JSON payload from a network request, your color change is stuck in traffic. This is why onScroll events used to be so laggy—you were sending hundreds of messages across the bridge every second just to update a scroll position.

Pro-tip: If you’re still using the old architecture, never send large blobs of data across the bridge. If you have a base64 image, don’t pass it as a prop. Save it to a temporary file on the native side and pass the file path (a string) instead.

The New Architecture: JSI and TurboModules

React Native is currently in a transition period. They’re moving to the “New Architecture,” which replaces the Bridge with the JavaScript Interface (JSI). This is a game-changer, but it’s also a source of immense technical debt for teams that don’t know how to migrate.

The JSI allows the JavaScript engine to hold a reference to a C++ host object. This means JS can call methods on native objects synchronously. No more JSON serialization. No more batching. If you want to call a native function to get the current battery level, it’s a direct function call. This is how react-native-reanimated v2+ achieves 60fps animations—it runs the animation logic on a separate worklet that interacts directly with the native UI views via JSI.


// Example of what a JSI-based native module looks like in C++
// This is not your standard "copy-paste" JavaScript.
#include <jsi/jsi.h>

using namespace facebook::jsi;

void install(Runtime& jsiRuntime) {
  auto getCpuTemp = Function::createFromHostFunction(
    jsiRuntime,
    PropNameID::forAscii(jsiRuntime, "getCpuTemp"),
    0,
    [](Runtime& runtime, const Value& thisValue, const Value* arguments, size_t count) -> Value {
      // Direct access to native system calls here
      float temp = nativeGetSystemTemperature(); 
      return Value(static_cast<double>(temp));
    }
  );
  jsiRuntime.global().setProperty(jsiRuntime, "getCpuTemp", std::move(getCpuTemp));
}

The downside? You now need to know C++. The “Write Once, Run Anywhere” dream just added a “Learn C++ or die” requirement. Most web developers coming to React Native aren’t ready to debug a segmentation fault in a .cpp file inside their node_modules.

Hermes: The Engine That Could

For years, Android performance in React Native was garbage compared to iOS. This was largely because the JavaScriptCore (JSC) engine on Android was outdated and unoptimized. Enter Hermes. Hermes is a JavaScript engine optimized for mobile. It doesn’t just run JS; it changes how the app is built.

Instead of shipping raw JS code to the device, the Metro bundler compiles your JS into Hermes Bytecode during the build process. This means the device doesn’t have to parse and compile the JS at runtime. This significantly reduces TTI (Time To Interactive). If your app takes 5 seconds to show the splash screen, you probably haven’t enabled Hermes.

But Hermes isn’t perfect. It lacks some modern JS features (though it’s catching up) and its garbage collector can be aggressive. I’ve seen cases where Hermes OOM-killed an app because it couldn’t reclaim memory fast enough during a heavy map() operation on a 50,000-item array. In those cases, you have to go back to basics: use for loops, avoid object spreading in tight loops, and manually nullify large references.

Dependency Hell and the CocoaPods Nightmare

In a standard web project, npm install is usually the end of the story. In React Native, it’s the beginning of a horror movie. Every time you add a library that has native dependencies, you have to deal with autolinking.

On iOS, this means running cd ios && pod install. Sounds simple? Wait until you have two libraries that depend on different versions of OpenSSL. Or worse, a library that hasn’t been updated to support use_frameworks! in your Podfile. I once spent a whole day debugging a Symbol(s) not found for architecture x86_64 error because a transitive dependency was compiled for arm64 only, and Xcode’s build system decided to be unhelpful about it.


# A typical Podfile "hack" to fix build errors
post_install do |installer|
  installer.pods_project.targets.each do |target|
    target.build_configurations.each do |config|
      config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '13.0'
      config.build_settings["EXCLUDED_ARCHS[sdk=iphonesimulator*]"] = "arm64"
    end
  end
end

If you find yourself adding blocks like the one above to your Podfile, you’re officially a React Native developer. You’re no longer a JS dev; you’re a build engineer who happens to write some React.

Performance: The FlatList Trap

Everyone uses FlatList. It’s the standard way to render lists. But FlatList is a memory hog if misconfigured. It doesn’t actually “recycle” views in the way a native RecyclerView or UITableView does. It just unmounts components that are off-screen. The JS objects for those components still exist in memory until the GC kicks in.

If you have a list with complex items (images, buttons, nested views), your RAM usage will climb as the user scrolls. To fix this, you have to use getItemLayout. Without it, React Native has to measure every item as it renders, which happens on the JS thread. If your items have dynamic heights, you’re in for a world of hurt. The list will “jump” as items are measured and rendered asynchronously.

  • windowSize: Set this to something small. The default is 21 (10 screens up, 10 down). That’s insane. Set it to 5.
  • removeClippedSubviews: On Android, this is a lifesaver. On iOS, it can cause weird flickering. Test accordingly.
  • initialNumToRender: Set this to exactly what fits on the screen. No more, no less.
  • maxToRenderPerBatch: Lower this to keep the JS thread responsive.
  • updateCellsBatchingPeriod: Increase this to give the bridge more breathing room.

If you really need performance, stop using FlatList and use FlashList from Shopify. It actually recycles views and is significantly faster because it minimizes the number of component mounts/unmounts.

The “Write Once” Lie: Android vs. iOS

You will write Platform.OS === 'ios' ? ... : ... more often than you’d like. Layout is handled by Yoga, which implements Flexbox. But Flexbox on mobile doesn’t behave exactly like Flexbox in Chrome. Shadows are a prime example. On iOS, you have shadowColor, shadowOffset, etc. On Android, you have elevation. They look nothing alike. You can’t even control the shadow color on Android without a third-party library that wraps the view in a native layer.

Then there’s the keyboard. Handling the keyboard in React Native is a rite of passage. KeyboardAvoidingView works about 60% of the time. The other 40% of the time, you’ll be manually calculating the keyboard height using Keyboard.addListener and adjusting your bottom padding. And don’t get me started on the difference between “software” buttons on Android and the “home indicator” on iOS. If you don’t use SafeAreaView (and use it correctly), your UI will be buried under the notch or the status bar.

The Real World: The “White Screen of Death”

Here’s a “Gotcha” that only hits you in production. You’ve tested your app on 10 different devices. It works perfectly. You push to the Play Store. Suddenly, you get reports of a “White Screen of Death” on certain Android 10 devices. No crash logs in Sentry. No errors in the console. Just… nothing.

The culprit? ProGuard. When you build a release version of an Android app, ProGuard (or R8) obfuscates your code to reduce size. If you’re using a library that uses reflection (common in many native modules), ProGuard might strip out classes that it thinks are unused but are actually required at runtime. You have to manually add -keep rules to your proguard-rules.pro file for every library that breaks.


# The fix for that one obscure library that crashes on release builds
-keep class com.facebook.react.bridge.CatalystInstanceImpl { *; }
-keep class com.facebook.react.bridge.WritableNativeMap { *; }
-keep class com.stripe.android.** { *; }

Debugging this is a nightmare because it only happens in release builds. You can’t use the debugger. You have to rely on adb logcat and hope you can find a stack trace that hasn’t been mangled beyond recognition.

State Management: Stop Overcomplicating It

The React Native community loves to over-engineer state. I’ve seen apps with 50 lines of code and 5,000 lines of Redux boilerplate. In a mobile environment, your biggest enemy isn’t “state synchronization”—it’s memory and battery. Redux, with its immutable state updates, can create a lot of garbage for the GC to clean up if you’re updating a large store 60 times a second (e.g., for a timer or a sensor).

Use Zustand. It’s lightweight, it doesn’t wrap your entire app in a Provider (which can cause unnecessary re-renders of the whole tree), and it’s easy to use outside of React components. If you need to persist state, redux-persist is a bloated mess. Use react-native-mmkv. It’s a JSI-based key-value store that is orders of magnitude faster than AsyncStorage because it doesn’t have to go over the bridge.


import { MMKV } from 'react-native-mmkv'

const storage = new MMKV()

// Synchronous write - no 'await' needed!
storage.set('user.session_token', 'sk_live_51Mz...')

// Synchronous read
const token = storage.getString('user.session_token')

Using AsyncStorage for everything is a classic mistake. AsyncStorage is literally just a serialized JSON file on the disk. Every time you read/write, you’re doing disk I/O and bridge serialization. For a high-performance app, that’s unacceptable.

The CI/CD Pipeline: Where Dreams Go to Die

Building a React Native app locally is one thing. Building it in a CI environment like CircleCI or GitHub Actions is another. You have to manage:

  1. Node.js version (use .nvmrc).
  2. Ruby version (use .ruby-version for CocoaPods).
  3. Java SDK version (Android needs 11 or 17 depending on the RN version).
  4. CocoaPods cache (to avoid 15-minute build times).
  5. Android Gradle cache.
  6. Apple Certificates and Provisioning Profiles (use Fastlane Match).

If any one of these is slightly off, your build will fail with an error message that looks like it was written in ancient Sumerian. My advice? Use a dedicated mobile CI like Bitrise or AppCenter if you have the budget. If you don’t, invest heavily in Fastlane. Fastlane allows you to automate the entire process—from incrementing build numbers to uploading screenshots to the App Store. Without it, you will lose hours every release cycle to manual clicking in Xcode.

The Testing Myth

“Write tests,” they say. “It will be fun,” they say. Testing React Native is a minefield. Jest is great for unit testing your logic, but it runs in a Node environment, not a mobile one. It uses react-test-renderer, which doesn’t actually render native components. If you want to test if a button is actually clickable on a real device, you need Detox.

Detox is an “un-gray box” E2E testing framework. It’s powerful, but it’s also flaky. It requires a specific version of applesimutils and can fail because the simulator took 2 seconds too long to boot. In my experience, the ROI on E2E tests for React Native is lower than for web. Focus on high-quality unit tests for your business logic and manual QA for the UI. The “automated UI testing” dream is often a time-sink that yields more false positives than actual bugs.

Security: Don’t Be Lazy

I’ve decompiled enough React Native APKs to know that most developers are lazy about security. Your .env file is not secret. If you put an API key in your JS code, I can find it in 5 minutes using strings index.android.bundle. For sensitive keys (like Stripe or AWS), never store them on the client. Use a proxy backend. If you absolutely must store something sensitive (like a user’s JWT), use react-native-keychain which wraps the iOS Keychain and Android Keystore/SharedPrefs with hardware encryption.

Note to self: Remind the team to never log the entire Redux/Zustand state in production. I found a 500MB log file on a user’s device once because someone left `redux-logger` enabled in the release build.

The Future: Is It Still Worth It?

With Flutter gaining ground and native development becoming easier with SwiftUI and Jetpack Compose, is React Native still relevant? Yes. But not for the reasons you think. It’s not about “sharing code.” It’s about the iteration cycle. The ability to push an update to your app via Expo Updates or CodePush without waiting 3 days for Apple’s review process is a superpower. It allows you to fix critical bugs in minutes.

But that superpower comes at the cost of complexity. You are managing a stack that is significantly deeper than a standard web or native app. You are responsible for the JS engine, the bridge/JSI layer, the native UI components, and the build tooling for two different operating systems.

One Final Piece of Advice

Stop trying to make your React Native app look exactly like your website. A mobile app is not a website in a box. Respect the platform conventions. Use the native header. Use the native back button behavior. And for the love of all that is holy, profile your app on a $100 Android phone, not just your $1,200 iPhone 15 Pro. If it doesn’t run smoothly on the cheap hardware, you haven’t built a mobile app; you’ve built a resource hog that happens to have an app icon.

Related Articles

Explore more insights and best practices:

Leave a Comment