DEV Community

Cover image for Swift Calls JSI Directly in Expo SDK 56: Removing the Objective-C++ Layer
Dan for Expo

Posted on • Originally published at expo.dev

Swift Calls JSI Directly in Expo SDK 56: Removing the Objective-C++ Layer

SDK 56 makes JavaScript to native calls significantly faster on iOS by letting Swift talk to JSI directly. We eliminated the Objective-C++ layer and saw 1.6-2.3x performance improvements across our benchmarks.

Before this change, every native module call went through three languages. Now it's just Swift making a direct C++ call. Here's how we did it and what the performance gains look like.

The three-language problem

Prior to SDK 56, calling an Expo native module from JavaScript meant crossing multiple language boundaries. Your Swift module code sat behind an Objective-C++ translation layer (EXJavaScriptRuntime, EXJavaScriptValue, etc.), which then called into JSI's C++ implementation.

This architecture existed for one reason: Swift couldn't talk to C++ directly. Objective-C++ was the only practical bridge between them.

The performance cost was significant. Every call crossed two language boundaries in each direction. Each value got converted twice: std::stringNSString → Swift String, std::vectorNSArray → Swift Array. Each conversion allocated memory and copied data.

Three different languages in the call path meant three different ways to debug problems. Stack traces changed shape mid-call. Memory management worked differently at each layer. When something went wrong, you had to understand all three languages to fix it.

Swift/C++ interop changes the game

Swift historically needed Objective-C as a bridge to reach C++. Any C++ type had to be wrapped in an Objective-C class before Swift could use it.

Swift/C++ interop (introduced in Swift 5.9) removes this requirement. Swift can import C++ headers directly. The compiler automatically maps C++ classes and methods onto Swift types you can use naturally.

The result: what used to be a three-language relay race becomes a single Swift expression that compiles down to a direct C++ call. Performance matches what you'd get writing the call in C++ from the start.

We're not the first to explore this in React Native. Nitro Modules pioneered this approach when Swift/C++ interop was even less mature.

Building ExpoModulesJSI

ExpoModulesJSI is our Swift package that wraps JSI in Swift types. Despite the name, it's purely a JSI wrapper with no Expo-specific code. We could ship it standalone, but JSI only exists in React Native contexts, so the naming stays conservative.

The type system mirrors JSI exactly: JavaScriptRuntime, JavaScriptValue, JavaScriptObject, JavaScriptArray, JavaScriptFunction, etc. Each maps to its JSI equivalent but with a modern Swift API.

We preserve JSI's ownership model using non-copyable types. JSI's value types like jsi::Value and jsi::Object own runtime resources and follow move-only semantics. Swift 5.9's ~Copyable protocol lets us mirror this behavior. The Swift compiler enforces the same single-owner rules that JSI expects underneath.

The package builds as a SwiftPM package with C++ interop enabled, then gets bundled into an xcframework. Most React Native projects use CocoaPods, so we also provide a podspec that wraps the prebuilt binary. The podspec creates a stub xcframework at pod install time, then a build script runs the real SwiftPM build with content-hash caching.

Handling different concurrency models

React Native's threading predates Swift Concurrency. JavaScript runs on a dedicated thread with a run loop. Native work uses dispatch_queue_ts and callbacks. No actors, no await points, just queues and blocks with thread-switching contracts.

We wanted our Swift API to use modern Swift: async/await, structured concurrency, actor isolation where appropriate. This required building a bridge between Swift Concurrency and React Native's callback world without breaking either system's invariants.

The boundary layer handles most of this work. We'll skip the implementation details here since they could fill another post and the design is still evolving under production load.

Implementation challenges

Swift/C++ interop is experimental and comes with compilation costs. Here are the main issues we encountered:

Experimental status. Years after Swift 5.9, C++ interop remains opt-in and officially experimental. APIs and behavior can change between Swift versions. Not a blocker for us, but worth knowing.

Capability gaps. Swift and C++ have different memory models. ARC and value semantics versus manual lifetime management and raw pointers. Complex template metaprogramming and some inheritance patterns have no clean Swift mapping. Some gaps will close with tooling improvements; others are conceptually unbridgeable.

Compilation performance. Enabling C++ interop adds noticeable compile time per file. It also spreads: any module importing an interop-enabled module must enable interop too. We solve this by shipping prebuilt xcframeworks. Apps link against binaries instead of recompiling interop sources, and downstream modules see a regular Swift library.

Generated headers. Swift emits a C++ header exposing all public symbols to C++. This gets large quickly and sometimes emits declarations in the wrong order. There's an undocumented flag -clang-header-expose-decls=has-expose-attr that restricts the header to explicitly annotated declarations. It's mentioned only in FrontendOptions.td in the Swift compiler source.

Third-party type annotations. Swift imports C++ classes as value types by default, but types with virtual methods need reference semantics. For code we control, Swift provides macros like SWIFT_SHARED_REFERENCE. For third-party headers like JSI, we use Clang's APINotes - YAML files that add import attributes without modifying the original headers.

Exception handling. C++ exceptions don't cross into Swift. Swift assumes imported C++ functions don't throw unless proven otherwise. When JSI methods like evaluateJavaScript throw jsi::JSError, the exception crashes the app if it reaches Swift frames. We built a bridge that catches C++ exceptions, stores them in thread-local storage, and rethrows them as Swift errors after each call.

Performance benchmarks

Our goal was simple: don't sacrifice performance for better Swift APIs. Turbo Modules set the bar for modern React Native native modules, and we wanted to match that performance while providing superior ergonomics.

Methodology

We tested four micro-benchmarks across three native module architectures on two SDK versions. Each benchmark ran 100,000 iterations, averaged across three trials on an iPhone 16 Pro release build:

  • Sync no-op function
  • Adding two numbers (0 + 1)
  • String concatenation ('hello' + 'world')
  • Async no-op function

The architectures tested were Expo Modules, React Native Turbo Modules, and the legacy Bridge. We used trivial inputs intentionally - these measure boundary crossing costs, not computation costs.

Note on async testing: Expo Modules use Swift Concurrency (async/await), which requires more work per call than callback-style async (Task creation, continuations, scheduler interaction). Turbo Modules and Bridge use callbacks. This compares the same logical operation done idiomatically in each system.

Results

CODE_BLOCK_N

Expo Modules became 1.6-2.3x faster across all benchmarks. The improvements match our architectural changes: boundary costs dominated the no-op test, marshaling costs affected strings most, and async showed the largest absolute gains due to removed overhead.

Before SDK 56, Expo Modules trailed Turbo Modules on every test. After the rewrite, we match Turbo Modules on simple sync calls and lead by 55% on async operations. The async advantage matters most in real apps where promises chain across module boundaries.

Turbo Modules also improved between SDK 55 and 56 from upstream React Native changes, so we were catching up to a moving target.

The Bridge results show the old story: 3-4x slower on sync operations due to JSON serialization overhead. The async gap narrows to 1.6x because Promise allocation and scheduling costs affect all architectures similarly.

Limitations

These micro-benchmarks measure boundary crossing costs. Real app performance depends on call frequency, payload size, and actual work being done. Device differences, OS versions, and Hermes builds will shift absolute numbers, but the performance ratios should remain consistent.

What comes next

Removing the Objective-C++ layer makes previously difficult features straightforward to implement. It also opens up performance optimizations that are now practical with a single-language call path.

This rewrite provides the foundation for the next round of API improvements we're planning.

Using SDK 56 native modules

SDK 56 ships the new native module architecture on iOS, tvOS, and macOS. Check the SDK 56 release notes for complete details. The expo-modules-jsi package is available on GitHub for bug reports, feature requests, and contributions.

Android takes a different approach in SDK 56. The major win there is our Kotlin compiler plugin, which moves more work to compile time and delivers larger performance gains than a JSI rewrite would provide. We may explore a Kotlin-first JSI wrapper eventually, but Android's JSI performance was already in better shape.

One final note: AI significantly accelerated this rewrite. It covered almost the entire JSI C++ surface in Swift and pushed test coverage to nearly 90%. Doing this work manually would have taken much longer.

This post is based on content from the Expo blog. Follow @expo for more React Native content.

Top comments (0)