Skip to main content
Back to module
Mobile App Production · Release

When to go native

Reading49mLesson 3 of 3 in this module

Explain how JavaScript and native code connect and decide when a native module is worth it.

Why it matters

React Native and Expo cover most of what apps need, but sooner or later a project hits something they do not: a vendor's payment terminal SDK that only ships for Swift and Kotlin, a brand-new OS feature, a performance-critical image filter. Mid-level mobile developers are expected to recognise when that point arrives and to know how JavaScript and native code meet, even if a native specialist writes the Swift or Kotlin.

How JavaScript talks to native code

In React Native's New Architecture (the default since React Native 0.76):

  • JSI (JavaScript Interface) lets JavaScript call C++ objects directly and synchronously, instead of sending serialized messages over the old asynchronous bridge.
  • Turbo Modules are native modules (functions) exposed through JSI and loaded lazily, when first used.
  • Fabric is the renderer that turns React's component tree into native views; custom native components (views) plug into it.
  • Codegen reads a typed JavaScript/TypeScript spec of your module and generates the native glue code, so both sides agree on the types.

In Expo projects the usual tool is the Expo Modules API, which lets you write a module in Swift and Kotlin with a small, declarative syntax:

// ios/BatteryModule.swift
import ExpoModulesCore

public class BatteryModule: Module {
  public func definition() -> ModuleDefinition {
    Name("Battery")
    Function("level") { () -> Float in
      UIDevice.current.isBatteryMonitoringEnabled = true
      return UIDevice.current.batteryLevel
    }
  }
}
// android/.../BatteryModule.kt
class BatteryModule : Module() {
  override fun definition() = ModuleDefinition {
    Name("Battery")
    Function("level") {
      val bm = appContext.reactContext?.getSystemService(Context.BATTERY_SERVICE) as BatteryManager
      bm.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY) / 100f
    }
  }
}
// JavaScript
import { requireNativeModule } from "expo-modules-core";
const Battery = requireNativeModule("Battery");
console.log(Battery.level());   // 0.82

The same name and function exist on both platforms, and JavaScript calls one API. (Expo already ships expo-battery; this is only to show the shape.) Because the module contains native code, it needs a development build; it cannot run in Expo Go. Config plugins let a module change native project settings (Info.plist entries, Android manifest permissions) automatically during the build.

When to go native

Reach for native code when:

  • A required SDK is native-only and no maintained React Native wrapper exists.
  • You need a new platform feature (widgets, Live Activities, App Clips, watch apps, CarPlay) that has no library yet. Many of these are separate native targets anyway.
  • Measured performance is not good enough after JavaScript-side fixes: heavy image or audio processing, real-time graphics, large on-device computation.
  • Deep OS integration: background services, Bluetooth peripherals with strict timing, custom keyboards.

Stay in JavaScript when a well-maintained library already does it (check its downloads, recent releases and New Architecture support), or when the "slowness" has not been measured. Every native module adds two languages to maintain, two sets of platform tests and upgrade work with each React Native release.

At the far end, some teams write whole screens or the whole app natively (Swift with SwiftUI, Kotlin with Jetpack Compose). That is a product decision about team skills and long-term cost, not just a technical one. Brownfield apps go the other way: an existing native app adds React Native for some screens.

Worked example: a decision

A delivery app must print receipts on a Bluetooth printer whose vendor ships only an iOS framework and an Android AAR.

  1. Search for an existing React Native wrapper: one exists but has had no release in three years and does not support the New Architecture. Not a safe dependency.
  2. Decide: write a small Expo module with two functions, connect(address) and printReceipt(lines), wrapping the vendor SDKs, with a config plugin that adds the Bluetooth permission strings.
  3. Keep the module thin: formatting the receipt stays in JavaScript (tested with Jest); native code only talks to the printer.
  4. Plan for maintenance: a native engineer reviews the Swift and Kotlin, and the module gets its own tests on each platform.

Common mistakes

  • Going native for performance before profiling.
  • Large native modules full of business logic that is hard to share and test; keep logic in JavaScript and native code thin.
  • Abandoned community libraries without New Architecture support.
  • Forgetting that native changes need a new store build and a new runtime version for OTA updates.
  • Implementing a feature on one platform only and leaving the other to crash.

Quick recap

  • The New Architecture connects JavaScript and native code through JSI, Turbo Modules, Fabric and Codegen.
  • In Expo, the Expo Modules API and config plugins are the standard way to add native code; it requires a development build.
  • Go native for native-only SDKs, new platform features, measured performance needs and deep OS integration; otherwise prefer maintained libraries.

Try it

Take the quiz in the Do tab. If you have time, list the native features your dream app would need and check whether a maintained library covers each one.

Also in this lesson: