::Product·React Native

Talking to JSI in Swift: what changed in SDK 56

In SDK 56, Expo's native modules call JSI directly on Apple platforms. The Objective-C++ layer is gone, and calls are 1.6–2.3x faster.

Tomasz Sapeta

Tomasz Sapeta

Engineering

Talking to JSI in Swift: what changed in SDK 56

TL;DR

In SDK 56 we rewrote the native module infrastructure on Apple platforms so Swift talks to JSI directly. The Objective-C++ layer that used to sit in the call path is gone. Calls are faster, the stack is simpler, and a few things that were previously awkward become straightforward. This post is about the Apple side. The Android changes in SDK 56 (a Kotlin compiler plugin) get their own writeup.

The old stack and why it had to change

Before SDK 56, a JavaScript call into an Expo native module on Apple platforms went through three languages. Swift module code sat on top of a layer of Objective-C++ bindings (EXJavaScriptRuntime, EXJavaScriptValue, EXJavaScriptObject, and friends), which sat on top of JSI in C++. The Objective-C++ layer was there for a single reason: Swift couldn't call C++ directly, and Objective-C++ was the only practical glue.

The cost showed up everywhere. Each call paid two language boundaries on the way in and two more on the way out, and every value got reshaped twice in each direction: std::stringNSString ↔ Swift String, std::vectorNSArray ↔ Swift Array, and so on. Each hop allocated and copied. Three languages had to be maintained in the hot path, and three languages also had to be reasoned about whenever something went wrong. Stack traces, types, and ownership all changed shape mid-call, which made debugging across the seam painful.

It set a ceiling on how fast and how clean the API could be, and that ceiling is what the rewrite is about.

Enter Swift/C++ interop

Until recently, mixing Swift and C++ in the same project meant going through a mix of Objective-C and C++, usually called Objective-C++. Swift could talk to Objective-C, and Objective-C++ files could freely interleave Objective-C and C++. So any C++ type you wanted in Swift had to be wrapped in an Objective-C class first. The old stack was doing this on every call.

Swift/C++ interop, introduced in Swift 5.9, removes the middle hop. Swift can import C++ headers directly. The compiler maps C++ types onto Swift types: classes and structs become Swift types you can construct, methods become Swift methods. No Objective-C class in between, no NSString/NSArray reshape on the way through. Coverage isn't total. Templates, for one, don't come through as Swift generics. But the bulk of an idiomatic C++ API surface does, and C++ types arrive with their ownership model intact. Combined with ~Copyable for the move-only ones, that's enough to preserve JSI's single-owner discipline up to the Swift API.

The payoff is that a JSI call goes from a three-language relay race to a single Swift expression that lowers to a direct C++ call. Per-call cost is what you'd get from writing it in C++ to begin with. The cost moves elsewhere instead: compile times, plus some sharp edges around the boundary that we'll cover below.

We're not the first to try this in the React Native space. Nitro Modules got there earlier, at a time when Swift/C++ interop was even less mature than it is now.

Designing ExpoModulesJSI

ExpoModulesJSI is the Swift package that wraps JSI in idiomatic Swift types. Despite the name, it knows nothing about Expo Modules; it's purely a Swift wrapper over JSI, and in principle we could ship it standalone. We don't, because it depends on React Native (which is where JSI lives) and JSI has essentially no users outside React Native. So the name is conservative on purpose.

The core type system mirrors JSI one-to-one: JavaScriptRuntime, JavaScriptValue, JavaScriptObject, JavaScriptArray, JavaScriptFunction, JavaScriptArrayBuffer, JavaScriptTypedArray, JavaScriptBigInt, JavaScriptNativeState. Each maps to a JSI type, but exposed as a modern, type-safe Swift API.

We mirror JSI's ownership semantics with non-copyable types. Many of JSI's value types are movable and non-copyable by design in C++. Take jsi::Value or jsi::Object: they own runtime resources, so the ownership is clear. At any point, exactly one place holds the value, and you have to be explicit if you want a second. Swift 5.9 gave us the right tool to mirror that on our side: ~Copyable. Our wrappers conform to it, so the Swift compiler enforces the same single-owner discipline JSI assumes underneath. No accidental copies, no accidental ref-count surprises, no API where a value silently becomes two.

The package itself is a SwiftPM package, which is where the C++ interop is enabled and where the xcframework is produced. The build script compiles a slice per platform (iOS device, iOS simulator, tvOS, tvOS simulator, macOS), each with interop on and the header escape hatch wired in, then bundles them into a single xcframework.

Most React Native projects pull native dependencies in through CocoaPods, so we also ship a podspec that wraps the prebuilt xcframework. The podspec creates a stub xcframework at pod install time so CocoaPods generates the right build phases, then a script phase invokes the real SwiftPM build (with content-hash-based caching so it doesn't rebuild on every pod install). So ExpoModulesJSI is consumable from a Podfile, the build itself lives in SwiftPM where Swift/C++ interop has first-class support, and downstream module authors never see any of this.

Bridging two concurrency models

React Native's threading model predates Swift Concurrency by years. JS work runs on a dedicated JS thread driven by a run loop; native work hops around via dispatch_queue_ts and NSRunLoop. There are no actors, no await points, no structured cancellation; just queues, blocks, and the contract that you call back on the right thread.

We wanted the public Swift API to look like modern Swift: async/await, structured concurrency, actor isolation where it fits. Swift Concurrency is part of a broader move across modern languages; Kotlin, C#, and others have made similar shifts away from callback chains. That meant designing a layer where Swift Concurrency and React Native's run-loop-and-dispatch world can coexist without either one corrupting the other's invariants. Most of the work was in the boundary. We're going to skip the specifics here, partly because they'd take another post on their own, partly because the design is still settling under load.

Tradeoffs and gotchas

Swift/C++ interop is still experimental, transitive, and slow to compile. That shaped more of the design than any single API quirk did. Here's what we'd want another team to know before they start.

Swift/C++ interop is still experimental. Years after Swift 5.9 shipped, the feature remains opt-in (.interoperabilityMode(.Cxx) in Package.swift) and is officially documented as evolving, which means APIs and behavior can shift between Swift versions. Worth being aware of, but not a blocker for us.

Some things aren't possible, and some never will be. Swift and C++ have very different memory and ownership models. ARC and value semantics on one side; manual lifetime, RAII, and raw pointers on the other. Plenty of C++ idioms have no clean Swift projection: complex template metaprogramming, some inheritance patterns, anything leaning on non-trivial move/copy semantics. Some of them are a tooling gap that will close over time. Some of them are conceptually unbridgeable. You design around them, so we did.

Slow builds, and interop spreads through a module graph, so we ship a prebuilt xcframework. Two related problems pushed us toward binary distribution. First, turning on C++ interop adds noticeable compile time per file, and it compounds across a module graph. Second, enabling interop in a Swift module forces every downstream module that imports the source to enable interop too. We didn't want either of those costs to spread into every Expo app. So we prebuild ExpoModulesJSI into an xcframework and ship that as the public artifact. App builds link against the binary instead of recompiling interop-enabled sources, downstream modules import it as a regular Swift library, and the interop boundary stays inside ExpoModulesJSI. Module authors never touch a build setting.

The generated C++ header is huge, and sometimes wrong. Swift emits a C++ header exposing every public symbol to C++. With a non-trivial Swift surface, this gets large fast and probably contributes to the slow builds. We've also seen it emit declarations in the wrong order, producing C++ that won't compile until you reshape the Swift API. Workable; just know it can happen.

There's an undocumented escape hatch: -clang-header-expose-decls=has-expose-attr restricts the C++ header to declarations explicitly annotated for export. The flag isn't in any official documentation we could find; the only public mention is a few lines in FrontendOptions.td in the Swift compiler source. Using it noticeably shrinks the generated header and sidesteps some of the ordering issues.

Annotating C++ types we don't own: APINotes. By default, Swift imports every C++ class and struct as a value type, like a Swift struct. That's a problem for anything with virtual methods, because in Swift, virtual dispatch only works on reference types (classes). A virtual jsi::Runtime::evaluateJavaScript imported as a value type isn't callable. You have to tell Swift to import the type as a reference. For code you control, Swift exposes macros you can drop into the C++ header (SWIFT_SHARED_REFERENCE and friends). But jsi::Runtime lives in React Native, and we didn't want to fork or patch the JSI headers. Clang's APINotes is the way through. It's a sidecar YAML file that layers Swift import attributes onto third-party headers without modifying them. We use it to mark jsi::Runtime as a reference type, which is what lets its virtual methods come through to Swift at all. Nearly every JSI call goes through one of those, so this is load-bearing.

C++ exceptions don't cross the Swift boundary. In Swift, throwing functions are marked throws and the compiler enforces it at the call site. C++ has no equivalent. Any function can throw unless it's marked noexcept. When Swift imports a C++ function it has no signal to distinguish the two, so it assumes the function doesn't throw. If one does, the app crashes. That's also a problem when reading the imported API: nothing in a Swift-imported C++ signature tells you whether a function can throw. You're left reading the C++ source, or guessing, or treating every imported call as potentially fatal. In JSI specifically: several core methods (evaluateJavaScript, property access on a thrown JS value, and others) really do throw jsi::JSError. If the exception escapes into Swift, the stack unwinder tears through frames that weren't compiled to expect it and the app crashes. Our mitigation is a small bridge that catches exceptions on the C++ side, stashes them in thread-local storage, and rethrows them as Swift errors after each call. The same path runs in reverse for Swift errors raised from host callbacks. You have to build the throws plumbing yourself, because the compiler won't.

Expo Module performance

Our goal with this rewrite was clear: don't pay a performance tax for the better Swift API. Turbo Modules represent the bar React Native sets for a modern native module architecture, and we wanted to meet that bar, not trade speed for ergonomics. The Swift Concurrency support, the ~Copyable ownership model, and the modern type system are what we wanted to ship; matching Turbo Module performance was the constraint that made it real. The numbers below are how we know we hit it.

Benchmark methodology

Four micro-benchmarks, three native module architectures, two SDK versions. The benchmarks call from JavaScript into native, 100,000 iterations each, three trial runs averaged. The architectures are the Expo Module path (this post's subject), React Native's Turbo Modules (the JSI-based modern path in core), and the legacy Bridge, which in current React Native is essentially a Turbo Module with an interop layer. The benchmarks: a sync no-op function, adding 0 + 1, concatenating 'hello' and 'world', and an async no-op. Trivial inputs are deliberate; these measure the cost of crossing the JS-native boundary, not the cost of arithmetic or string work. All numbers below are from an iPhone 16 Pro on a release build.

One note on the async benchmark. The Expo Module path uses Swift Concurrency (async/await), which is more work per call than callback-style async: a Task, a continuation, scheduler interaction. Turbo Modules and the Bridge use callback-style. So this isn't the same machinery in three implementations; it's the same logical operation done idiomatically in each world. We think that's the honest comparison; you can write an async function in your module's Swift code as async func, and the cost shown here is what that ergonomics costs (or in our case, doesn't).

Benchmark results

We ran the same suite on SDK 55 (the last release before this rewrite) and SDK 56. Turbo Modules and the Bridge are included for context.

Benchmark SDK 55 Expo SDK 56 Expo SpeedupSDK 56 Turbo SDK 56 Bridge (interop)
Sync no-op135 ms68 ms2.0x70 ms214 ms
Adding two doubles212 ms92 ms2.3x93 ms379 ms
Concatenating strings220 ms137 ms1.6x150 ms430 ms
Async no-op1219 ms706 ms1.7x1091 ms1158 ms

The Expo Module path got 1.6–2.3x faster across the suite. Every benchmark improved by a substantial margin, and the improvements track exactly the architectural changes we'd expect to matter: boundary cost dominates the no-op, marshaling cost dominates strings, and the async path sees the largest absolute gain because it had the most overhead to remove.

In SDK 55, the Expo Module path trailed Turbo Modules on every benchmark: by 11% on async, ~17% on sync no-op and strings, 66% on adding doubles. After the rewrite the picture flips. We match Turbo Modules on the simplest sync paths (within rounding error), edge ahead by 9% on string marshaling, and lead by 1.55x on async. That async gap is the one we'd point at first. It's where overhead actually accumulates in real apps, where promises chain across module boundaries and work gets scheduled and resumed.

One honest aside on the comparison. Turbo Modules and the Bridge moved a little between SDK 55 and SDK 56 too, mostly from upstream React Native improvements (Turbo got noticeably faster on addNumbers specifically; both shifted a few percent on the others). That just means we weren't catching up to a stationary target. We were closing the gap to an actively-improving one, and now we're slightly ahead of it.

The Bridge column tells the older story. On synchronous work it's 3–4x slower than either JSI-based path, which is exactly the gap you'd expect from JSON serialization on every call. On async it narrows to 1.6x, because async is dominated by Promise allocation and scheduling overhead that all three architectures pay roughly equally.

Caveats

Micro-benchmarks measure micro-things. Doing nothing 100,000 times tells you the boundary cost; it doesn't tell you what a real app feels like, where call frequency, payload size, and the cost of the actual work usually dominate. Different devices, OS versions, and Hermes builds will move the absolute numbers around; the ratios are the durable part of the story.

What this unlocks

  • Easier path to features that were painful or impossible through Objective-C++.
  • Future perf work that's now tractable because the call path is one language.

This isn't the end of the road. There's more coming on top of this foundation than we'll cover here; this rewrite is the platform for the next round of API work.

Get started with Expo SDK 56 native modules

SDK 56 ships the new native module path on iOS, tvOS, and macOS. The SDK 56 release notes have the rest of what's in this version; the expo-modules-jsi package is on GitHub if you want to file a bug, suggest a design change, or contribute.

Android takes a different shape. The big SDK 56 win there is the Kotlin compiler plugin (covered in its own writeup), which moved more work to compile-time and delivered larger gains than a JSI rewrite would have. We're also planning to look at a Kotlin-first wrapper over JSI eventually, but Android's JSI story is healthier than iOS's was, so the gains from that direction are likely to be modest.

One more thing: AI was a huge help on the rewrite. It covered almost the entire JSI C++ surface in Swift and pushed test coverage to nearly 90%. Doing it by hand would have taken much longer.

Expo Modules
Performance

Share article