How we made Hybrid iOS apps compile 39% faster with React Native Brownfield

Authors

React Native Brownfield lets you embed React Native (and Expo) screens into an existing native iOS or Android app, then ship that integration as a distributable library: an XCFramework on iOS, an AAR on Android. The brownfield package:ios command drives most of that work: it runs the Xcode build, collects Hermes, Brownfield helpers, and your app framework, and writes everything under ios/.brownfield/package/build/.

For a long time, one step in that pipeline remained problematic for iOS: compiling React Native itself from source. React Native has been shipping prebuilts for Android long before, but iOS remained problematic, making us (devs) feel frustrated waiting for build times, and wasting CI time.

However, starting with React Native 0.83 and Expo 55, React Native started shipping with Apple prebuilts. Instead of waiting minutes for React Native to be compiled locally, the NPM package contains precompiled XCFrameworks for the RN core (React, ReactNativeDependencies) that CocoaPods can link instead of building thousands of C++ and Objective-C files on every packaging run. React Native 0.81 made prebuilts opt-in; from 0.84 they are the default upstream. Brownfield 3.10+ supports React Native 0.83 and later.

In Brownfield projects, prebuilts start with React Native 0.83 as an opt-in feature, then became the default in higher versions. In the case of Expo, versions ≥ 55 are using prebuilts by default.

Brownfield 3.10.0 (PR #323) wires those prebuilts into the packaging flow, and does a bunch of other stuff (described below). The Brownfield CLI & Expo config plugin are already version-aware and use appropriate defaults (see the Brownfield docs section for React Native & section for Expo).

Prebuilts do not change the Brownfield integration model. Your host app still loads an XCFramework and presents React Native like any other native module. Prebuilts remove a large, repeatable compile step from the packaging path. For teams shipping brownfield bundles through CI, that is often the difference between a tolerable pipeline and one that blocks iteration.

What is packaged with Brownfield?

A typical Brownfield iOS package contains:

  • <YourFramework>.xcframework - your React Native app, bundled JS included
  • ReactBrownfield.xcframework - the Brownfield runtime (view controllers, host setup)
  • hermesvm.xcframework - the Hermes VM
  • Optional Brownie.xcframework, BrownfieldNavigation.xcframework, and Expo support frameworks when those packages are in the project

When prebuilts are enabled, two more artifacts appear:

  • React.xcframework
  • ReactNativeDependencies.xcframework

The native host app links them directly, so that during compilation time, the Xcode build skips compiling RN core sources and instead pulls React-Core-prebuilt through CocoaPods (inside RN internally, this is controlled by the RCT_USE_PREBUILT_RNCORE=1).

Enabling prebuilts

The CLI exposes an explicit switch:

npx brownfield package:ios --use-prebuilt-rn-core

You can pass true, false, or omit the flag entirely. Below is a summary of the relation between RN versions and the default behavior:

Project type RN / Expo version Default
Vanilla RN < 0.81 Unsupported
Vanilla RN 0.81 - 0.83 Off by default; pass --use-prebuilt-rn-core to opt in
Vanilla RN ≥ 0.84 On by default
Expo < SDK 55 Unsupported
Expo ≥ SDK 55 On by default

If you try to enable prebuilts on an unsupported version, don't worry: the CLI will fail early before running the build, so you don't end up with a cryptic Xcode error halfway through a 500-second build.

What changed under the hood

Prebuilts were not a single-flag change. A few changes were made to the packaging flow to make it work with prebuilts.

CocoaPods & Xcode build flags

On the CocoaPods side, ReactBrownfield.podspec conditionally adds the prebuilt dependency:

if ENV['RCT_USE_PREBUILT_RNCORE'] == '1'
  spec.dependency 'React-Core-prebuilt'
end

Brownfield also sets BUILD_LIBRARY_FOR_DISTRIBUTION, SWIFT_EMIT_MODULE_INTERFACE, and related flags on ReactBrownfield, Brownie, and BrownfieldNavigation podspecs so Release builds emit .swiftinterface files. That is required for xcodebuild -create-xcframework to merge device and simulator slices reliably.

@rpath install names

Expo-generated framework targets were producing dylibs with absolute install names instead of @rpath/.... At runtime, this would cause the host app to only work on the machine that built the libs (#310).

Therefore, we implemented a fix in the Expo config plugin to inject the following build settings:

DYLIB_INSTALL_NAME_BASE = "@rpath"
LD_DYLIB_INSTALL_NAME = "@rpath/$(EXECUTABLE_PATH)"

on the Brownfield framework target so packaged artifacts use relocatable install names.

Interface-only helper frameworks

When the host app embeds both your main framework (which already contains linked symbols) and separate Brownie or BrownfieldNavigation XCFrameworks, you can hit duplicate symbol errors at link time.

Brownfield strips the binary from those helper XCFrameworks after creation, leaving interface-only frameworks: Swift can import Brownie, but the implementation comes from the main library. The same stripping applies to {scheme}.xcframework when you pass --scheme.

Analysis of build times before vs after

We benchmarked brownfield package:ios on the repository demo apps, comparing from-source and prebuilt runs on identical hardware. Each configuration was repeated 10 times, with the first run being a cold build (from scratch). Reported p-values are in the sense of the Mann-Whitney U test (α = 0.05).

If we take a look at all run times, including the cold & subsequent builds, we get:

App From-source (mean) Prebuilt (mean) Build time Δ p-value
ExpoApp55 503 s 305 s −39.3% 0.0091 (significant)
RNApp 218 s 155 s −29.0% 0.0493 (significant)

Cold starts (first run after a clean) dominate wall time on Expo projects. CocoaPods resolution, DerivedData, and Expo's larger native surface all add variance. Restricting to steady-state runs (run index ≥ 2) isolates the compile-time savings more clearly:

App From-source (mean) Prebuilt (mean) Build time Δ p-value
ExpoApp55 464 s 283 s −39.2% 0.0047 (significant)
RNApp 167 s 149 s −11.2% 0.0243 (significant)

The first thing that we may notice: in all scenarios, we’re seeing improvements of build time that are not only noticeable with a naked eye, but also statistically significantly better compared to the legacy from-source baseline.

Expo sees the largest gain because its dependency graph is heavier; vanilla RNApp still benefits, but a smaller share of total time is spent in RN core compilation once the rest of the pipeline is warm. If we compare percentage build time gains, we see that for Expo, they are consistent across cold and subsequent builds. For vanilla React Native, we get an ~11% boost in subsequent builds, compared to a ~29% boost in all builds.

Try it

Upgrade to @callstack/react-native-brownfield ≥ 3.10.0 and run packaging with defaults on a supported project:

npx brownfield package:ios --scheme YourFramework --configuration Release

On RN 0.83 or when comparing builds, toggle explicitly:

# for Vanilla RN:
npx brownfield package:ios --use-prebuilt-rn-core true
npx brownfield package:ios --use-prebuilt-rn-core false

# for Expo:
npx brownfield package:ios --use-prebuilt-expo true
npx brownfield package:ios --use-prebuilt-expo false

Full flag behavior, embed checklist, and Expo notes are in the iOS integration docs.

Conclusions

For teams shipping hybrid apps, this is a straightforward way to remove friction from the iOS packaging flow. React Native prebuilts shorten a long compile step, and give teams back time on every packaging run. If you’re using React Native Brownfield 3.10+ and React Native 0.84+ (Expo 55+), you get this behaviour out-of-the-box. In React Native 0.83, it is worth enabling prebuilts and measuring in your own setup.

Table of contents
Adding React Native to existing apps?

We help teams introduce React Native into brownfield projects effectively.

Let’s chat

Insights

Learn more about Brownfield

Here's everything we published recently on this topic.