Cut React Native Build Times by 75% with Artifact Caching


Reducing your build times is achievable by recycling your native build artifacts. In this guide, I will walk you through the steps required to implement this process effectively.
The native builds are expensive
Every time you trigger the CI to create a new build, it will create new IPA and APK files. This can take a lot of time, depending on your project size. These builds will contain the latest JS bundle. Most of the time, you don’t change native code, so why not keeping the previous IPA/APK and just replace the new JS bundle?
When to go native
In this article we will use CircleCI and Fastlane to do the job, most of the code is shell based, so this can be adjusted to any other CI service.
The first step is know when a new native build is required, we will run a git diff
and here are the paths and files to check:
- No cached IPA/APK found - this means that even if our diff contains JS code only, we cannot recycle a non-existent native build.
- Any change in
/ios
. - Any change in
/android
. - Any change in
/src/assets
, in theory this could also be recycled, but we will keep it simple for now. - Any change in
/.env*
. Depending on the env lib that you use, these env variables can be used inside native code. - Any change in
/patches
, if you’re usingpatch-package
, you’re probably patching native code. - Any change in
/yarn.lock
, this one is useful to trigger Android builds, on iOS we already know if a native dependency was changed because the/ios/Podfile.lock
will be changed.
The order of these checks is important here.
Here is the CircleCI command for this:
and it can be used like this:
steps:
- is-native-build-required:
platform: android
Save and restore a cached IPA/APK
Here are three important things that we need here:
- Cache the IPA/APK after a native build was finished.
- Store the build in a different location for each environment and platform.
- Have a way to manually reset this cache.
- Use the latest cached build for a specific platform (iOS/Android) and for a specific environment (dev/staging/prod).
CircleCI doesn’t have the best tool for this job, but something close enough, is to use the save_cache
and restore_cache
commands.
Here is how to do this for the Android builds:
This command has one param, env
, to know for which environment we save the APK.
The key
param from the save_cache
is constructed using:
env
- a variable to know for which environment we store.v1
- a hard coded string, to be able to manually reset the cache by changing it to v2, v3, […]pipeline.git.revision
- the git SHA that is being built. This is added automatically by CircleCI, it may have a different name on other CI services. We want this to make sure we use the latest cached build, even if it’s a recycled one. Ideally, would be to recycle only the native builds and ignore the recycled ones.
The paths
param is used to tell CircleCI where is the file/dir that we need to cache, here you can specify multiple paths if you output multiple APKs.
The next step is to restore this cache when needed, we will use this command:
Really similar with the previous command except the key
param from restore_cache
. We don’t add the last part. CircleCI will choose the latest cache entry that matches the first parts of the key.
The iOS part is really similar with this one, you can see it here.
The restore-android-apk-cache
, restore-ios-ipa-cache
must be called before is-native-build-required
and save-android-apk-cache
, save-ios-ipa-cache
must be called after the build part is finished.
Have the new version and build number ready
Before refreshing the APK/IPA files, we need to know the next version and build number, depending on your setup this may vary but a good practice for React Native projects is to use the version
field from package.json
. Let’s define a new command in CircleCI to read this and keep it in an env variable.
The build number should be fetched from Firebase/TestFlight/AppStore Connect/Google Play and incremented or updated as you wish, this will not be covered here. Fastlane has a lot of tools for this:
- latest_testflight_build_number
- firebase_app_distribution_get_latest_release
- google_play_track_version_codes
Refresh the recycled APK file
Having the cached builds ready to be used and the flag env variables (NATIVE_ANDROID_BUILD_REQUIRED
, NATIVE_IOS_BUILD_REQUIRED
) to know when to use them, we can start on Android and refresh the APK file with the new JS bundle.
This implementation may vary from project to project but here is what we want:
- Unarchive the APK file using
apktool
. - Delete the old JS bundle from there.
- Create a new un-minified JS bundle using the current source files.
- Compile the JS bundle to bytecode (if you’re using Hermes).
- Copy the new JS bundle inside the APK dir.
- Update APK’s build number and version.
- Zip back the APK dir.
- Sign the APK file.
- Move it back in the cached path defined in the
save_cache
command, so CircleCI will be able to find it.
This time, we will do it inside Fastfile
, but this is basically a shell script so feel free to adjust it for your project.
# [...]
new_build_number = latest_release[:buildVersion].to_i + 1
if ENV["NATIVE_ANDROID_BUILD_REQUIRED"] == "true"
# Do your regular fastlane steps
# [...]
else
UI.message("Skipping native Android build")
refresh_apk_js_bundle(
build_number: new_build_number.to_s
)
# Distribute the new build, the path is "./mobile_app.apk"
end
# [...]
The refresh_apk_js_bundle
action can be defined in the same directory with your Fastfile, actions/refresh_apk_js_bundle.rb
.
This fastlane action will use wget
, yq
, apktool
and bundletool
, if your CI machine does not contain these, you can install them by creating a new file inside the fastlane
dir, install-tools-android.sh
.
Now having these tools ready, we can refresh the APK file. In the first part we call the install-tools-android.sh
script, use apktool
to unzip the APK file and delete the old JS bundle file.
In the next part we create a new JS bundle, compile it to bytecode using Hermes and copy the new bundle inside the APK dir.
The last steps are to upload the new source maps to your favorite crash reporting service, set the new version and build number inside apktool.yml
, create the new APK and sign it.
Complete source for the refresh_apk_js_bundle.rb
can be found here.
Refresh the recycled IPA file
The iOS part is similar with the Android one, first we unzip the IPA file, create a new JS bundle, compile it to bytecode using Hermes and copy it back in the original location.
Next, we sign the IPA using a signing_identity
and provisioning_profile(s)
defined by Fastlane in some ENV variables. You can replace com.bundle.identifier
with your own identifier. This fastlane action has a new parameter, match_type
, it can have the following values: adhoc
, appstore
, development
or enterprise
.
Complete source for the refresh_ipa_js_bundle.rb
can be found here.
For the iOS lane, this action can be used like this:
# [...]
latest_release = firebase_app_distribution_get_latest_release(app: firebase_app_id)
if ENV["NATIVE_IOS_BUILD_REQUIRED"] == "true"
update_project_settings(
profile_type: "AdHoc",
build_number: (latest_release[:buildVersion].to_i + 1).to_s
)
# Do your regular fastlane steps
# [...]
else
UI.message("Skipping native iOS build")
refresh_ipa_js_bundle(
match_type: "adhoc",
build_number: (latest_release[:buildVersion].to_i + 1).to_s
)
# Distribute the new build, the path is "./mobile_app.ipa"
end
# [...]
Good to know
- On Android, this will not work for
.aab
files, they are really different compared to an APK. - Would be nice to create a new env variable to store the artifact file name and use it inside the shell scripts in both fastlane actions,
sh("something #{ENV["PRODUCT_NAME"]}.apk")
- The
save_cache
andrestore_cache
commands are saving every new build even if it’s a recycled one. Unfortunately, there is no way to delete the old cache or skip certain build steps in CircleCI. This cache expires automatically after 15 days. - The APK sign logic is duplicated on Android, if you change something inside
./android/app/build.gradle
make sure to also update therefresh_apk_js_bundle.rb
file. - This articles assumes that you use the default JS bundle file names,
index.android.bundle
andmain.jsbundle
. - If your project is using
productFlavors
&flavorDimensions
on Android and multiplebuild schemes
&build configs
on iOS, more customizations are needed to accommodate the things explained above.
Sources
That was it
Recycling native build artifacts can significantly reduce build times and improve the efficiency of your CI/CD pipeline, especially for React Native projects. By leveraging this, you can save valuable time.
While the approach outlined in this article focuses on CircleCI and Fastlane, the scripts are platform agnostic or at least easy adjustable to other CI/CD tools and workflows.
Feel free to write your question in the comments section bellow and let’s connect on LinkedIn.
Comments