How to reduce build time by 60% with CI build optimization for React Native apps

We made some improvements to our Reach Native demo app and reduced build time by nearly 60%. Check out what optimizations we made and how we sped up the build time on our demo app.

We recently explored improvements to Bitrise’s demo apps. In particular, we modified the React Native demo app — not only to optimize our DevOps platform for the fastest possible feedback and performance — but also to show you just how fast Bitrise can be. The goal was to make the React Native demo app build as fast as possible on Bitrise so users can get fast feedback from their builds. Read on to find out how we reduced build time by nearly 60%!

What is a demo app?

Demo apps are sample projects — maintained by Bitrise Mobile Experts — which customers can use to try features of Bitrise on a sample codebase. A user may want to explore Bitrise before demonstrating its benefits to their team. So, we created demo apps so users can explore Bitrise immediately. We optimize demo app workflows (found in the bitrise.yml in each demo app repository) to build as fast as possible in order to showcase Bitrise's ability to provide developers with fast feedback.

Why do we need fast feedback from our CI?

If you adopted continuous integration practices into your development workflow, your CI/CD pipeline builds, tests, and deploys your app. Therefore, your mobile CI/CD pipeline directly affects the rate you’re able to deliver new updates to your customers.

Slow CI builds that take more than a few minutes cause interruptions

Imagine pushing, switching to a new branch (both in git and mentally), and then getting a notification 40 minutes later about a broken build on the original branch. Now you’ve interrupted the original problem and your new workflow — what a mess!

These things really add up. More importantly, they cause developers to move with caution. Push and then… do nothing until we get a green build. That’s a lot of wasted time, and developers feel like they’re not productive. It hurts morale, which is even worse!

Fast CI builds maintain flow and produce high-quality apps

We should consider the benefits of Sustainable Flow. It used to be that it took 15 minutes to get back into a state of Flow. More recent studies show that the true time is closer to 25-30 minutes. Fast feedback means that we don’t break Flow. We address things immediately and can then move on to new workloads with confidence!

Bottom line: In order to maintain a high-quality product, fast feedback is important. Fast feedback helps you to quickly test changes made to your app while shipping new features and bug fixes faster.

The baseline

Before making any changes, we took an initial measure of the time it took to build our React Native demo app on Bitrise. We can see that build time is especially important when developing software, but React Native builds are not known for their speed.

From the above breakdown for this build, we can see that the first build took nearly 14 minutes to run!

Some notable rows here are Run Cocoapods install and Xcode Build for Simulator, which are really inflating our build time.

The changes and optimizations

Using the latest version of React Native

The first thing we did when looking into what optimizations we could make was to consider what we gained by updating to the latest version of React Native. At the beginning of the project, we started with an older React Native version of 0.70.5. When considering where to get our information, we decided to review official sources first; so from the official React Native blog post on the release of version 0.71.0, we found that there are a few build speed optimizations made specifically surrounding the javascript engine which React Native makes use of: Hermes.

Disabling Flipper on iOS

As Bitrise mobile experts, we scrutinize everything that goes on in a build backed with the knowledge of some of the most important parts of our build systems. This includes the dependency management phase of an app.

While investigating our project with fresh eyes we noticed a dependency in our Cocoapods called Flipper which the iOS build was spending a lot of time on. Flipper is a dependency included by default when creating a new React Native project using the react-native create ... command. While referring to the official React Native documentation, we came across this page about speeding up CI builds. From the documentation:

Flipper is a debugging tool shipped by default with React Native, to help developers debug and profile their React Native applications. However, Flipper is not required in CI: it is very unlikely that you or one of your colleague would have to debug the app built in the CI environment.

The first thing suggested on that page is to disable Flipper. We accomplished this by updating our Podfile to make use of the CI environment variable exposed by Bitrise. To achieve the same result, in your project you can change the following line in your Podfile from:

:flipper_configuration => FlipperConfiguration.enabled,

to

:flipper_configuration => ENV['CI'] ? FlipperConfiguration.disabled : FlipperConfiguration.enabled,

Using build parallelization

After exhausting our search of build optimizations from official sources, we took a look at how our CI pipeline was set up. When we started we had the following primary workflow in our Bitrise config:

...
trigger_map:
- pull_request_source_branch: "*"
  workflow: primary

primary:
    description: Runs JavaScript tests, and verifies iOS and Android builds
    before_run:
    - setup
    after_run:
    - test-js
    - build-ios
    - build-android
  setup:
    steps:
    - git-clone@8: { }
    - restore-npm-cache@1: { }
    - npm@1:
        inputs:
        - command: install
    - save-npm-cache@1: { }
  test-js:
    steps:
    - npm@1:
        inputs:
        - command: test
  build-ios:
    description: Builds the iOS version of the app for use in Simulators
    steps:
    - react-native-bundle@1:
        inputs:
        - entry_file: index.js
        - platform: ios
    - restore-cocoapods-cache@1: { }
    - cocoapods-install@2: { }
    - save-cocoapods-cache@1: { }
    - xcode-build-for-simulator@0:
        inputs:
        - project_path: ios/BitriseReactNativeSample.xcworkspace
        - scheme: BitriseReactNativeSample
        - simulator_device: iPhone 13
  build-android:
    description: Builds the Android version of the app for use in emulators
    steps:
    - react-native-bundle@1:
        inputs:
        - entry_file: index.js
        - platform: android
    - install-missing-android-tools@3:
        inputs:
        - gradlew_path: android/gradlew
    - restore-gradle-cache@1: { }
    - android-build@1:
        inputs:
        - module: app
        - project_location: android
        - variant: debug
    - save-gradle-cache@1: { }
meta:
  bitrise.io:
    machine_type_id: g2-m1.8core

This is a pretty straightforward configuration. You can read this from top to bottom and things will be executed in that order. In order to kick things off via a pull request we specify the primary workflow as the one to be triggered. In this workflow:

  1. First, we do some setup work via our setup workflow like cloning the source control repository and running the npm install command in order to install our node dependencies.
  2. Next, in test-js, we run our Javascript tests.
  3. Once our tests pass, we install our Cocoapods and run an iOS build in the build_ios workflow.
  4. Finally, we install some Android dependencies and run and Android build in the build_android workflow.

Note: We also recommend you to make use of caching in your build to speed things up. We were already making use of caching in our builds from the beginning for this project so we decided not to cover it in this article, but you can learn more here.

One thing that jumped out at us right away is that there’s no pipelines section in our configuration. This means we're running each phase of our workflow sequentially. We realized that the iOS and Android apps could be compiled independently. We then realized that the tests could also be run independently from the two builds. To reduce build time even further, we leveraged our new feature — Build Pipelines — that allows us parallelize parts of our build.

We wanted to run our tests and create our iOS and Android apps in parallel. So, we added them to our test-and-build stage. Workflows in stages are executed in parallel. Next, we need to tell Bitrise when to run this stage. We'll create a primary pipeline to run this stage.

Here’s what our config file includes after rewriting it to include parallelization:

...
trigger_map:
- pull_request_source_branch: "*"
  pipeline: primary

pipelines:
  primary:
    description: Runs JavaScript tests, and verifies iOS and Android builds
    stages:
    - test-and-build: {}

stages:
  test-and-build:
    abort_on_fail: true
    workflows:
    - test-js: {}
    - build-ios: {}
    - build-android: {}

workflows:
  setup:
    steps:
    - git-clone@8: {}
    - restore-npm-cache@1: {}
    - npm@1:
        inputs:
        - command: install
    - save-npm-cache@1: {}
  test-js:
    before_run:
    - setup
    steps:
    - npm@1:
        title: Run tests
        inputs:
        - command: test
  build-ios:
    before_run:
    - setup
    description: Builds the iOS version of the app for use in Simulators
    steps:
    - react-native-bundle@1:
        inputs:
        - entry_file: index.js
        - platform: ios
    - restore-cocoapods-cache@1: {}
    - cocoapods-install@2: {}
    - xcode-build-for-simulator@0:
        inputs:
        - project_path: ios/BitriseReactNativeSample.xcworkspace
        - scheme: BitriseReactNativeSample
        - simulator_device: iPhone 13
        - configuration: Debug
    - save-cocoapods-cache@1: {}
  build-android:
    before_run:
    - setup
    description: Builds the Android version of the app for use in emulators
    steps:
    - react-native-bundle@1:
        inputs:
        - entry_file: index.js
        - platform: android
    - restore-gradle-cache@1: {}
    - android-build@1:
        inputs:
        - module: app
        - project_location: android
        - variant: debug
    - save-gradle-cache@1: {}

meta:
  bitrise.io:
    machine_type_id: g2-m1.8core

Here are the changes we made in a nutshell:

  1. Pipelines run stages sequentially and stages run workflows in parallel, so we removed the primary workflow definition and instead added a primary pipeline definition. Our primary pipeline runs a single stage: test-and-build.
  2. We made sure to include the abort_on_fail: true flag in our stage to abort the other workflows in case one of them fails. This can help save resources in the case that a unit test fails and the app builds are still running.
  3. The test-and-build stage runs our existing test-js, build-ios, and build-android stages in parallel.

Per workflow stacks

Another thing we noticed is this bit at the bottom of our config:

meta:
  bitrise.io:
    machine_type_id: g2-m1.8core

This means that we’re utilizing the same M1 8-core stack for all of our workflows.

Since we’re utilizing pipelines now it’s important to understand that each workflow in a stage executes in a separate environment. This enables us to specify a different stack per workflow meaning we can do things like building our Android app on a Linux stack, and our iOS app on an M1 stack. Unfortunately, this also means that we introduce some redundancy in running our setup steps for each workflow executed by our stage.

We decided to utilize a Linux stack for stability and performance while building our Android app. Our build-android workflow changed to:

  build-android:
    before_run:
    - setup
    description: Builds the Android version of the app for use in emulators
    steps:
    - react-native-bundle@1:
        inputs:
        - entry_file: index.js
        - platform: android
    - restore-gradle-cache@1: {}
    - android-build@1:
        inputs:
        - module: app
        - project_location: android
        - variant: debug
    - save-gradle-cache@1: {}
    meta:
      bitrise.io:
        stack: linux-docker-android-20.04
        machine_type_id: elite

The results

As we can see, we have a total build time of less than 6 minutes after our above changes!

That includes unit testing and building both iOS and Android apps.

The conclusion

We set out to reduce the build time of our React Native app as much as possible and succeeded through the use of parallelization and a bit of mobile expertise. In addition to being a handy tool to evaluate Bitrise, we also attempted to encode some of the best practices in our example. Users are encouraged to not only try them, but also steal the best bits and make their builds better, faster, and stronger!

One more thing…

Since the iOS build was the limiting factor in our build time let’s take a look at the summary for it after all our changes:

By disabling Flipper in our CI builds, we reduced the build time from 6.3 min in our original build to 2.1 min in the final version. Parallelization helped us quite a bit in speeding up our build, but our mobile experts knew about this one special trick to really get the most out of the CI!

Sources

https://arxiv.org/pdf/1805.05508.pdf

Developer Flow State and Its Impact on Productivity

React Native 0.71: TypeScript by Default, Flexbox Gap, and more... · React Native

Using Hermes · React Native

Speeding Up CI Builds · React Native

Dependencies and caching

Build Pipelines

Get Started for free

Start building now, choose a plan later.

Sign Up

Get started for free

Start building now, choose a plan later.