building your own Swift and Kotlin bridge
When an existing library cannot close a gap in React Native, a custom native module is the only way forward. This article shows how the Swift and Kotlin bridge between JavaScript and native code is built, how Turbo Modules and JSI replace the old bridge, and how a cleanly typed native module comes together from the spec file to the finished JS wrapper.
Table of Contents
- 1. When you actually need a custom native module
- 2. The old architecture: the bridge and asynchronous serialization
- 3. The new architecture: Turbo Modules, Codegen and JSI
- 4. Writing a native module in Swift
- 5. Writing a native module in Kotlin
- 6. Emitting events from native to JS
- 7. Writing the JS/TypeScript wrapper
- 8. Configuring and running Codegen
- 9. Testing and debugging native modules
- 10. Summary
- 11. FAQ
1. When you actually need a custom native module
The JavaScript bridge in React Native already covers a large share of app requirements through core APIs and community libraries. A custom native module only pays off once there is a concrete gap in the React Native API surface that no existing library closes. This usually involves very specific platform capabilities: a new iOS framework that has no JavaScript binding yet, or an Android system service that is only reachable through Java or Kotlin APIs.
The second common reason is wrapping an existing native SDK, for example a payment provider, a hardware vendor's SDK, or a proprietary analytics system. These SDKs usually ship only Swift, Objective-C or Kotlin libraries, never a JavaScript variant. A native module acts as a translation layer here: it calls the native SDK method and hands the result back in a form JavaScript can consume. Performance-critical code, such as image processing, cryptographic operations, or sensor signal processing, belongs in this category too, because JavaScript execution is routinely too slow for that kind of work.
A third scenario that motivates a native module involves requirements that outlive the React Native app's own lifecycle, for example Bluetooth Low Energy connections that need to persist even when the app is closed, or background processes that require dedicated operating system APIs for background execution. These cases deserve their own dedicated articles, but they illustrate exactly why the native modules bridge between JavaScript and native code exists in the first place: it reaches capabilities that React Native does not expose on its own.
2. The old architecture: the bridge and asynchronous serialization
In the classic React Native architecture, often called the Old Architecture, the JavaScript thread and the native thread communicate exclusively through the bridge. Every method call and every return value gets serialized to JSON, sent across the bridge, and deserialized again on the other side. This process runs asynchronously regardless of whether the call would actually expect a synchronous answer. For a simple call such as reading a device name, this adds noticeable overhead that becomes the limiting factor once calls happen frequently, for example inside an animation or a sensor stream.
On iOS, a native module in this architecture exports methods through RCT_EXPORT_METHOD, on Android through the @ReactMethod annotation. Both mechanisms register the method at runtime in a method table that the bridge looks up on every call. This works reliably, but it carries two structural weaknesses. First, there is no type safety between the JavaScript call site and the native implementation, so typos in method names or mismatched parameter types only surface at runtime. Second, every registered native module gets initialized at app startup, whether it is actually used or not, which measurably slows down startup time in larger apps with many modules.
3. The new architecture: Turbo Modules, Codegen and JSI
The new React Native architecture replaces the JSON bridge with the JavaScript Interface, JSI for short. JSI lets JavaScript code hold a direct reference to native C++ objects and call their methods without a serialization step. For a native module, that means: instead of sending a message across the bridge and waiting asynchronously for the reply, a Turbo Module can expose methods that get called synchronously whenever that makes sense. The JSON detour disappears entirely, which delivers a substantial speed gain especially for frequent, small calls.
Turbo Modules are no longer registered manually in a method table, they get generated from a TypeScript specification file. This spec file describes the methods, parameters and return types of the native module as a TypeScript interface. A Codegen step reads this file at build time and generates matching native interface code for iOS and Android, against which your own Swift or Kotlin implementation then compiles. If the implementation drifts from the generated interface, the build fails, not a test case at runtime.
Another benefit of the new architecture is lazy loading: Turbo Modules only get instantiated once JavaScript actually references them, not unconditionally at app startup. In apps with many native modules, this directly affects startup time. Overall, the new architecture shifts error detection from runtime to build time and replaces the bridge with a much more direct call path through JSI.
| Aspect | Bridge (Old Architecture) | Turbo Modules (New Architecture) | Impact |
|---|---|---|---|
| Data transfer | JSON serialization, always asynchronous | Direct JSI access, synchronous where useful | No serialization overhead per call |
| Module initialization | All native modules at app startup | Lazy loading on first reference | Shorter startup time with many modules |
| Type safety | Manual, only visible at runtime | Codegen from TypeScript spec, build time | Typos break the build, not the app |
| Method registration | RCT_EXPORT_METHOD / @ReactMethod table | Protocol/base class generated from spec | Implementation follows a fixed interface |
| Debugging | Bridge messages hard to inspect | Direct call stack through JSI | Root cause easier to locate |
4. Writing a native module in Swift
There are two common ways to write a native module in Swift: the classic approach, a class implementing RCTBridgeModule through @objc annotations, or the Turbo Module approach, where the Swift class conforms to a protocol generated by Codegen. Both approaches share the same basic pattern: a method gets exported to JavaScript, accepts parameters, and returns its result through an RCTPromiseResolveBlock or an RCTPromiseRejectBlock instead of a classic return value.
On failure, the reject block passes along an error code, a readable error message, and optionally the underlying NSError. On the JS side, this arrives as a rejected promise carrying a code property that the calling code can act on directly. Constants that are fixed at app startup, for example SDK version numbers or default configuration, get exported through constantsToExport() instead of a dedicated method call, saving an extra bridge round trip.
Threading deserves particular attention in a Swift-based native module. By default, every native module runs on its own queue, not on the main thread. If an SDK method requires UI interaction, or has to run on the main thread for other reasons, for example because the SDK itself expects it, the implementation must explicitly hop over with DispatchQueue.main.async. Forgetting this produces hard-to-reproduce crashes that only surface under load or on specific iOS versions.
import Foundation
@objc(PaymentBridgeModule)
class PaymentBridgeModule: NSObject {
// Runs on the module's own queue by default, not the main thread
@objc
static func requiresMainQueueSetup() -> Bool {
return false
}
@objc(chargeCard:amount:resolver:rejecter:)
func chargeCard(cardToken: String, amount: NSNumber,
resolver resolve: @escaping RCTPromiseResolveBlock,
rejecter reject: @escaping RCTPromiseRejectBlock) {
// Some SDKs require the main thread for UI presentation
DispatchQueue.main.async {
PaymentSDK.charge(token: cardToken, amount: amount.doubleValue) { result in
switch result {
case .success(let receipt):
resolve(["transactionId": receipt.id, "status": receipt.status])
case .failure(let error):
reject("E_CHARGE_FAILED", error.localizedDescription, error)
}
}
}
}
@objc
func constantsToExport() -> [String: Any] {
return ["defaultCurrency": "EUR", "sdkVersion": PaymentSDK.version]
}
}
5. Writing a native module in Kotlin
On Android, a native module in the classic approach extends ReactContextBaseJavaModule and overrides getName() to define the name under which the module becomes visible in NativeModules on the JS side. In the Turbo Module approach, the Kotlin class instead extends an abstract base class generated by Codegen, which already prescribes the method signatures from the TypeScript spec. In both cases, methods get annotated with @ReactMethod and receive a Promise object as the last parameter, through which resolve() or reject() gets called.
An important difference from Swift: the JS thread on Android must never be blocked under any circumstances. Expensive SDK calls or file I/O therefore belong on a dedicated thread or executor, not directly inside the @ReactMethod function. If a long-running operation executes synchronously inside the module, the entire JavaScript execution of the app can freeze in the worst case, which shows up as a frozen UI even though the UI layer itself is not actually the cause.
For a new native module to become visible at all, it must be registered together with its ReactPackage in MainApplication.kt. Skip this step, and NativeModules.PaymentBridgeModule stays undefined on the JS side, with no obvious error message pointing at the cause, a stumbling block that regularly causes confusion when native modules are freshly set up.
package de.mironsoft.paymentbridge
import com.facebook.react.bridge.Arguments
import com.facebook.react.bridge.Promise
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.bridge.ReactContextBaseJavaModule
import com.facebook.react.bridge.ReactMethod
class PaymentBridgeModule(reactContext: ReactApplicationContext) :
ReactContextBaseJavaModule(reactContext) {
override fun getName(): String = "PaymentBridgeModule"
@ReactMethod
fun chargeCard(cardToken: String, amount: Double, promise: Promise) {
// Never block the JS thread, offload the SDK call to a worker thread
Thread {
try {
val receipt = PaymentSdk.charge(cardToken, amount)
val result = Arguments.createMap().apply {
putString("transactionId", receipt.id)
putString("status", receipt.status)
}
promise.resolve(result)
} catch (error: PaymentException) {
promise.reject("E_CHARGE_FAILED", error.message, error)
}
}.start()
}
}
// MainApplication.kt: register the package alongside the default packages
class MainApplication : Application(), ReactApplication {
override fun getPackages(): List<ReactPackage> =
PackageList(this).packages.apply {
add(PaymentBridgePackage())
}
}
6. Emitting events from native to JS
Beyond direct method calls, a native module often needs to report events to JavaScript on its own initiative, for example download progress or a status change on an external device. On iOS, a class derived from RCTEventEmitter handles this task. It implements supportedEvents() to declare the allowed event names, and calls sendEvent(withName:body:) whenever it needs to push an event to JavaScript.
On Android, this role belongs to the RCTDeviceEventEmitter, accessed through reactContext.getJSModule(DeviceEventEmitterModule::class.java).emit(eventName, params). Unlike iOS, there is no separate base-class pattern tied to the event name, emission happens directly through React Native's generic device event system. Both platforms expect a serializable payload, typically a map or a WritableMap, never an arbitrary native object.
On the JS side, you subscribe to these events through an instance of NativeEventEmitter, passed the native module itself as its argument. The return value of addListener() gives you a subscription object with a remove() method that must be called when the component unmounts. Forget this cleanup step, and every additional mount piles on another listener, leading to duplicate callback invocations and hard-to-trace memory leaks.
7. Writing the JS/TypeScript wrapper
A native module should never be used directly through NativeModules.PaymentBridgeModule across the rest of the app. Instead, a dedicated TypeScript wrapper encapsulates access, defines clean types for parameters and return values, and hides native implementation details such as error codes behind its own error class. This makes later refactors easier, because native details can change without touching call sites throughout the entire app.
Error handling deserves particular care here. A rejected promise from a native module typically delivers an object with code and message, from which the wrapper constructs its own, typed error class. Calling components can then react specifically based on the code, for example showing a user-facing message on E_CHARGE_FAILED, instead of treating every error as a generic crash.
import { NativeModules, NativeEventEmitter, Platform } from 'react-native';
const { PaymentBridgeModule } = NativeModules;
export interface ChargeReceipt {
transactionId: string;
status: string;
}
export class PaymentBridgeError extends Error {
constructor(public code: string, message: string) {
super(message);
}
}
// Clean typed API, native details stay hidden from callers
export async function chargeCard(cardToken: string, amount: number): Promise<ChargeReceipt> {
try {
return await PaymentBridgeModule.chargeCard(cardToken, amount);
} catch (error: any) {
throw new PaymentBridgeError(error.code ?? 'E_UNKNOWN', error.message ?? 'Charge failed');
}
}
const emitter = new NativeEventEmitter(Platform.OS === 'ios' ? PaymentBridgeModule : undefined);
// Subscribe to native events, for example status pushes from the SDK
export function onChargeStatusChanged(callback: (status: string) => void) {
const subscription = emitter.addListener('chargeStatusChanged', (event) => {
callback(event.status);
});
return () => subscription.remove();
}
8. Configuring and running Codegen
For Codegen to find and process a TypeScript spec file at all, the module's package.json must contain a codegenConfig block. Among other things, this block sets the internal name of the generated specification, the directory holding the spec files, and, for Android, the Java package name under which the generated code lands. The spec file itself follows a fixed naming convention: it always starts with Native, followed by the module name, for example NativePaymentBridgeModule.ts, and exports a TurboModule interface with the method signatures.
The actual Codegen run turns this spec file into platform-specific code: Objective-C++ headers and classes on iOS, abstract Java classes on Android that your own Kotlin implementation then compiles against. These generated files land in build directories that never get checked into the repository, they get regenerated from the spec on every build. Change the spec, and the expected native interface changes automatically along with it, making drift between the JS definition and the native implementation impossible, instead of quietly causing runtime errors.
In practice, a single Codegen run before the build is usually enough, followed by a pod install on iOS so CocoaPods picks up the newly generated podspecs. On Android, Gradle picks up the generated sources automatically, a separate step is generally not needed there. Anyone diagnosing Codegen issues is best off starting with a clean Codegen run and checking whether the generated files actually contain the expected method signatures.
{
"name": "payment-bridge",
"version": "1.0.0",
"codegenConfig": {
"name": "PaymentBridgeSpec",
"type": "modules",
"jsSrcsDir": "src/specs",
"android": {
"javaPackageName": "de.mironsoft.paymentbridge"
}
}
}
# Regenerate native interface code from the TypeScript spec file
npx react-native codegen
# iOS: install pods so CocoaPods picks up the generated podspecs
cd ios && pod install && cd ..
# Android: Gradle picks up generated Java sources automatically
# on the next build, no separate step needed
npx react-native run-android
9. Testing and debugging native modules
The native side of a module can be tested independently of React Native, something that gets overlooked in practice more often than it should. On iOS, XCTest exercises the Swift implementation directly, without needing a running app or a JavaScript bridge, for example to verify that a failure case actually returns the correct error code. On Android, JUnit fills the same role for the Kotlin implementation. These unit tests run considerably faster than end-to-end tests across the entire app and catch logic errors before they ever reach the JavaScript side.
Bridge or JSI failures rarely show up as a clean exception, they show up as inexplicable behavior: a promise that never resolves, an event that never arrives, or a crash with no meaningful stack trace. Native logging, through os_log on iOS or Logcat on Android, paired with targeted log statements at every handoff point between JS and native code, helps narrow down exactly where communication actually breaks.
Typical pitfalls with native modules repeat across projects. A JS callback retained inside a closure that never fires prevents the associated bridge resource from being released, causing a memory leak. A UI call that accidentally lands on the wrong thread causes hard crashes on iOS and at least warnings in the log on Android. And simply forgetting the package registration in MainApplication.kt means an otherwise correct native module simply does not exist on the JS side.
Mironsoft
React Native development, native modules and app infrastructure
An SDK that only exists natively, but is needed in React Native?
We build custom native modules in Swift and Kotlin, cleanly typed through Codegen, with complete error handling, event wiring, and tests for iOS and Android.
Native module development
Swift and Kotlin implementation including Turbo Module wiring
SDK integration
Translate existing native SDKs into a clean, typed JS API
Migration & debugging
Migrate existing bridge modules to Turbo Modules and JSI
10. Summary
A custom native module is not an end in itself, it is the answer to a concrete gap that neither a core API nor a community library closes: missing platform capability, an SDK that only exists natively, or code too performance-critical for JavaScript. The old bridge handles these cases through asynchronous JSON serialization, with RCT_EXPORT_METHOD on iOS and @ReactMethod on Android, but without type safety and with noticeable overhead. The new architecture replaces that with Turbo Modules, generated from a TypeScript spec, and JSI as a direct, sometimes synchronous access path without a serialization step.
A clean Swift and Kotlin bridge accounts for promise-based return values, exported constants, correct threading, and working event wiring through RCTEventEmitter and RCTDeviceEventEmitter. A typed JS wrapper hides the native implementation behind a stable API, Codegen keeps the spec and the native implementation in sync, and unit tests on both platforms catch logic errors long before they ever reach the app's surface.
Custom Native Modules in React Native: The Essentials
When you need one
Missing platform API, a native SDK with no JS binding, or performance-critical code too slow in JavaScript.
Bridge vs. JSI
The old bridge serializes every call asynchronously as JSON, JSI allows direct, sometimes synchronous access with no detour.
Swift & Kotlin
Promise-based methods, exported constants, and correct threading matter on both platforms, don't skip them.
Codegen & tests
The TypeScript spec drives Codegen, XCTest and JUnit secure the native side independently of the app.