React Native Guide: Build High-Performance Mobile Apps

React Native: The Bridge is Out and Your App is OOM-Killed

It was 3:00 AM on a Tuesday in 2021. I was the lead SRE for a fintech startup that had just launched a high-yield savings feature. We built the mobile app in react native because the “velocity” was supposed to be unmatched. We hit the top of Hacker News. Then, the alerts started firing. Our Sentry dashboard looked like a crime scene. Every single user on a mid-range Android device—think Samsung Galaxy A series—was experiencing a hard crash within 45 seconds of opening the app. The logs showed a consistent java.lang.OutOfMemoryError.

We had built a complex, beautiful dashboard with real-time SVG charts and a massive FlatList of transactions. The “Bridge”—that glorious architectural bottleneck that passes JSON messages between JavaScript and Native—was backed up like the 405 at rush hour. We were pushing 60 updates per second over the bridge to update a progress bar, and the serializing/deserializing overhead was eating the heap alive. I spent the next 18 hours rewriting the transaction list to use a windowing logic that the documentation barely mentioned, while the CTO breathed down my neck. We didn’t “unlock” anything that day; we just barely survived the “write once, debug everywhere” reality of cross-platform development.

The Documentation Lie: It’s Not Just JavaScript

If you read the official react native docs, they make it sound like you’re just writing React for phones. That is a dangerous oversimplification. In a real production environment, you are managing three distinct runtime environments simultaneously: the JavaScript VM (Hermes), the Android Runtime (ART/Dalvik), and the iOS Runtime (Objective-C/Swift). If you don’t understand how these three interact, you aren’t building an app; you’re building a ticking time bomb.

The documentation loves to talk about “Hot Reloading.” It rarely talks about the AppDelegate.mm or the MainApplication.java. It ignores the fact that when you need to integrate a specific SDK—like a high-security biometric scanner or a custom Bluetooth LE protocol—you will be writing C++ or Java. You cannot escape the native layer. Most teams fail because they hire five JS developers and zero engineers who know what a Podfile.lock actually does. They end up in “dependency hell” where react-native-reanimated version X requires react-native-gesture-handler version Y, but only if you’re using Gradle 7.4. It’s a house of cards.

The Architecture: The Bridge vs. JSI

For years, the Bridge was the heart of react native. It was asynchronous, which sounds good until you realize it’s also non-deterministic. You send a message from JS: “Hey, change this background color to blue.” The Bridge serializes that into a JSON string, sends it to the Native side, which deserializes it and updates the UI. If the Bridge is busy—say, because you’re fetching a 2MB JSON payload from api.stripe.com/v1/events—your UI interaction lags. This is why react native apps often feel “janky” compared to native ones.

The “New Architecture” replaces the Bridge with the JavaScript Interface (JSI). This is a game-changer, but it’s also a massive migration headache. JSI allows JavaScript to hold a reference to a C++ host object. You can call methods on it directly. No more JSON serialization. No more asynchronous overhead for simple UI updates. But here is the catch: to use it, your third-party libraries must support it. Most don’t. You’ll find yourself stuck in a hybrid state where half your app uses the old Bridge and the other half tries to use Fabric (the new UI renderer).

Pro-tip: If you are starting a new project in 2024, enable the New Architecture immediately. Do not wait. Retrofitting it into a 2-year-old codebase is a recipe for a month-long regression testing nightmare.


// Example of a TurboModule (New Architecture) definition
// This is TypeScript, but it generates C++ code under the hood.
import type {TurboModule} from 'react-native';
import {TurboModuleRegistry} from 'react-native';

export interface Spec extends TurboModule {
  getDeviceName(): string;
  setStorageItem(key: string, value: string): Promise<boolean>;
  // Note: JSI allows synchronous calls, but use them sparingly.
  getHardwareIdSync(): string;
}

export default TurboModuleRegistry.getEnforced<Spec>('DeviceHelper');

Hermes: The Engine You Can’t Ignore

If you aren’t using the Hermes engine, you are doing it wrong. Period. In the early days, react native used the JavaScriptCore (JSC) engine—the same one Safari uses. On iOS, it was fine because JSC is a system framework. On Android, you had to bundle the entire JSC engine into your APK, adding 20MB of bloat and terrible startup performance.

Hermes is a small, lightweight JS engine optimized for mobile. Its biggest trick? Pre-compilation. Instead of parsing JS at runtime, Hermes compiles your JS into bytecode during the build process. This slashes “Time to Interactive” (TTI). However, Hermes has quirks. Its garbage collector is aggressive. If you are creating thousands of short-lived objects in a useMemo hook inside a high-frequency loop, you will see “GC pressure” pauses. I’ve seen apps drop from 60fps to 15fps because someone was mapping over a large array inside a render function instead of using a selector.

  • Hermes does not support with statements (good, you shouldn’t use them anyway).
  • It has limited support for Proxy objects in older versions, which can break some state management libraries.
  • The debugger uses the Chrome DevTools protocol, but it’s finicky. If your app crashes on launch, the debugger usually won’t catch it because the engine hasn’t initialized yet.
  • Memory profiling in Hermes requires using the .heapsnapshot format, which you can load into Chrome, but interpreting it requires understanding how C++ allocates memory for JS objects.

The Native Module Trap

Eventually, you will need to do something JS can’t do. Maybe it’s background geolocation that doesn’t get killed by the OS, or maybe it’s custom image processing using OpenCV. This is where the “React Native is easy” myth dies. You have to write a Native Module.

On Android, this means Java or Kotlin. On iOS, Objective-C or Swift. But it’s not just the language; it’s the lifecycle. Your native module lives as a singleton. If you store state in your native module, you have to manually clear it when the JS bundle reloads. I once saw a bug where a user’s session persisted across logouts because the “AuthModule” in Java was holding a static reference to a token that the JS side thought it had deleted.


// Android Native Module: The reality of boilerplate
package com.myapp.storage;

import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.Promise;
import android.content.Context;
import android.content.SharedPreferences;

public class SecureStorageModule extends ReactContextBaseJavaModule {
    private static ReactApplicationContext reactContext;

    SecureStorageModule(ReactApplicationContext context) {
        super(context);
        reactContext = context;
    }

    @Override
    public String getName() {
        return "SecureStorage";
    }

    @ReactMethod
    public void setItem(String key, String value, Promise promise) {
        try {
            SharedPreferences sharedPref = reactContext.getSharedPreferences("secret_prefs", Context.MODE_PRIVATE);
            SharedPreferences.Editor editor = sharedPref.edit();
            editor.putString(key, value);
            editor.apply();
            promise.resolve(true);
        } catch (Exception e) {
            promise.reject("ERR_STORAGE", e.getMessage());
        }
    }
}

Notice the Promise. Everything across the bridge is a promise. If you have a tight loop that needs a value from native, you’re going to have a bad time. You’ll end up with “Promise hell” on the native side, which is even harder to debug than the JS version because the stack traces often disappear into the libfb.so binary.

State Management: Redux is a Sledgehammer

People love to argue about Redux vs. Zustand vs. Context. In react native, the choice matters more because of the single-threaded nature of JS. If you use Redux and your state tree is a 5MB JSON object, every time you dispatch an action, Redux does a shallow compare of the tree. On a desktop i9, that’s 1ms. On a $150 Android phone, that’s 40ms. You just dropped two frames of animation because you dispatched a KEYBOARD_OPEN action.

I prefer Zustand or MobX. Why? Because they allow for more granular updates. But even then, the real killer is the “Re-render Loop.” In a web browser, a few extra re-renders are usually fine. In react native, a re-render triggers a layout calculation in Yoga (the C++ layout engine). Yoga is fast, but it’s not free. If you have a deeply nested component tree, a single state change at the top can trigger hundreds of calls to measure() and layout() on the native side.

Use React.memo. Use useCallback. Use useMemo. These aren’t “optimizations” in react native; they are requirements for a functional app. If you don’t believe me, turn on the “Perf Monitor” in the dev menu and watch your JS thread FPS drop to 0 when you type in a text input that isn’t properly memoized.

The Build System: Where Dreams Go to Die

The most difficult part of react native isn’t the code. It’s the build system. You are dealing with npm, CocoaPods, and Gradle all at once. If one of them is unhappy, none of them are happy.

Take CocoaPods. It’s a Ruby-based dependency manager for iOS. If your machine has the wrong Ruby version (thanks, macOS default Ruby), pod install will fail with a cryptic error about ffi. Or maybe you’re on an M1/M2 Mac and you need to run arch -x86_64 pod install because some legacy library hasn’t been updated for ARM. Then there’s Gradle. Android’s build system is a beast that consumes RAM like a black hole. If you don’t give your Gradle daemon at least 4GB of heap (org.gradle.jvmargs=-Xmx4096m), your builds will take 10 minutes and then fail with a GC overhead limit exceeded error.

  • The Node Modules Black Hole: Your node_modules folder will eventually reach 2GB. rm -rf node_modules && npm install is the “have you tried turning it off and on again” of the RN world.
  • Version Mismatches: react-native version 0.73.x might require kotlinVersion = "1.8.0". If a plugin you use requires 1.9.0, Gradle will throw a fit. You’ll spend your afternoon in android/build.gradle overriding configurations.
  • Code Signing: Provisioning profiles and keystores. You will lose a week of your life to Apple’s “Missing Push Capability” emails because your Entitlements.plist wasn’t synced correctly by the CLI.
  • The Metro Bundler: Metro is the JS bundler. It’s fast, but it hates symlinks. If you’re using a monorepo (Lerna, Nx, Turborepo), you will need a metro.config.js that looks like an incantation from a dark magic ritual just to get it to find your shared packages.
  • Fastlane: If you aren’t using Fastlane to automate your deployments to TestFlight and Google Play, you are wasting hours every week. But Fastlane itself is another layer of Ruby that can break.
  • CI/CD Latency: A typical native build on GitHub Actions takes 15-30 minutes. You cannot iterate quickly if your feedback loop is half an hour long. You need a Mac mini under someone’s desk or a very expensive M1-based CI runner.

The “Gotcha”: The Keyboard and the Notch

Here is something no one tells you: handling the keyboard in react native is a nightmare. On iOS, the keyboard slides up and covers your inputs. On Android, the OS might resize the whole window, or it might just cover it, depending on the windowSoftInputMode in your AndroidManifest.xml.

You’ll reach for KeyboardAvoidingView. It won’t work. It will either jump too high or not high enough. You’ll end up using a third-party library like react-native-keyboard-controller because the built-in solution is fundamentally broken for complex layouts. Then there’s the “Safe Area.” Every phone has a notch, a punch-hole camera, or a rounded corner. If you don’t wrap your app in a SafeAreaProvider, your “Back” button will be hidden behind the system clock, and Apple will reject your app during review.

Note to self: Always test on a device with a “Dynamic Island” and a device with a bottom “Home Indicator.” What looks good on an iPhone 8 looks like garbage on an iPhone 15 Pro.

Performance Profiling: Beyond console.log

When your app is slow, console.log is useless. In fact, console.log makes it slower because the logs themselves have to be serialized and sent over the bridge to the debugger. I’ve seen apps speed up by 20% just by removing debug logs in production (use babel-plugin-transform-remove-console for this).

To actually fix performance, you need the Flashlight tool or the React DevTools profiler. You need to look at “Commit” times. If a commit takes more than 16ms, you are dropping frames. You need to look at the “Flamegraph.” Are you re-rendering the entire list because one item’s heart icon changed? That’s a failure of your memo strategy.

On Android, use systrace. It shows you exactly what the UI thread is doing. If you see long blocks of Choreographer#doFrame, the native side is struggling. If you see long blocks of mqt_js, your JavaScript is the bottleneck. Usually, it’s a bit of both, dancing together in a spiral of inefficiency.


// A common performance footgun
const MyComponent = () => {
  const [items, setItems] = useState([1, 2, 3]);

  // BAD: This function is recreated on every render
  // Any child component receiving this as a prop will re-render
  const handlePress = (id) => {
    console.log('Pressed', id);
  };

  return (
    <FlatList 
      data={items}
      renderItem={({item}) => <ListItem onPress={() => handlePress(item.id)} />} // EVEN WORSE: Inline arrow function
    />
  );
};

// BETTER:
const MyComponentFixed = () => {
  const [items, setItems] = useState([1, 2, 3]);

  const handlePress = useCallback((id) => {
    console.log('Pressed', id);
  }, []);

  const renderItem = useCallback(({item}) => (
    <ListItem id={item.id} onPress={handlePress} />
  ), [handlePress]);

  return (
    <FlatList 
      data={items}
      renderItem={renderItem}
      keyExtractor={item => item.id.toString()}
      removeClippedSubviews={true} // Crucial for large lists
      maxToRenderPerBatch={10}
      windowSize={5}
    />
  );
};

The Upgrade Path: A Tale of Woe

Upgrading react native is the single most painful task in software engineering. It’s not like npm update. It’s like performing a heart transplant while the patient is running a marathon. Between versions, the internal C++ APIs change, the Gradle plugin versions shift, and the CocoaPods structure gets reorganized.

I once spent three days upgrading from 0.68 to 0.70. The culprit? A change in how react-native-community/cli handled autolinking for native modules with custom search paths. The error message was just Linker command failed with exit code 1. I had to manually diff the project.pbxproj file—a file that is notoriously human-unreadable—to find a missing library reference.

Always use the React Native Upgrade Helper. It’s a web tool that shows you the diff between a clean project of your current version and a clean project of the target version. You have to manually apply those changes to your files. If you’ve heavily customized your native folders, God help you.

Is It Worth It?

After a decade of SRE work, I’ve seen the “Native vs. Cross-Platform” war play out a dozen times. react native is not a silver bullet. It is a trade-off. You trade runtime performance and build-system stability for development speed and a unified codebase. For 90% of business apps—CRUD apps, e-commerce, social media—it is the right choice. But that other 10%? If you are building a high-performance video editor, a complex 3D game, or a real-time signal processing app, react native will be a constant source of friction.

The “velocity” you gain in the first three months is often paid back with interest in the following two years as you fight the Bridge, the build system, and the upgrade cycle. You don’t get a “free” mobile app. You get a complex, multi-runtime system that requires a deep understanding of both web and mobile internals to maintain at scale.

If you’re going to use react native, stop treating it like a web project. Learn how ProGuard works. Learn how to read an Objective-C stack trace. Learn why Lottie animations can sometimes peg the CPU at 100%. The “magic” only works until it doesn’t, and when it fails, you’ll be glad you know what’s happening under the hood.

Stop chasing the latest state management library and start looking at your frame budget. Your users don’t care if you use Redux or signals; they care that the app doesn’t freeze when they try to pay for their coffee. Optimize your Bridge traffic, use Hermes, and for the love of all that is holy, test on a $100 Android phone before you push to production.

Related Articles

Explore more insights and best practices:

Leave a Comment