How StashAway Reduced Our React Native CodePush Bundle By 80%

Le Thanh An
StashAway Engineering
6 min readJun 12, 2023

--

At a fast-paced, high-growth start up, we often find the need to rapidly push new features and bug fixes into the hands of our consumers. CodePush is an excellent technology for this. CodePush allows us to send Over The Air (OTA) updates and skip the long App Store approval time. It is absolutely crucial for pushing out new features, time-sensitive bug fixes, etc.

However, we have received feedback that CodePush download time is slow, and the user experience is heavily impacted as a result. Thus, we set out to find a way to improve this experience.

🤔 | Problem Analysis

A quick glance at AppCenter told us that our CodePush bundles were a whopping 22.4 MB on average! Even on a 4G network, it would take a user 1 whole minute for the bundle to be downloaded! For users on mobile data, it would also eat up a lot of data! Not ideal. 😰

Upon diving deep into the bundle to see what could be removed, we found out that:

  1. Image Assets were being bundled
  2. sourcemap was being bundled

Bundling assets and sending them OTA every-time would be unnecessary as they as they rarely ever change. It would be much better to have assets live on the native app side.

The sourcemap is not necessary as well, as it was only used by monitoring tools for debugging, error monitoring, e.t.c. It doesn’t need to live on the production app, only on the monitoring tool of choice (in our case it was Sentry).

After we have discovered the above findings, we then wrote a script to calculate how much impact we could get from removing the assets folder and the main.jsbundle.map file. Seems like we could save approximately about 75% of our bundle size!

We then set out to remove the unnecessary main.jsbundle.map and the assets folder.

🗺 | Removing main.jsbundle.map

When we bundle up our app for release, the end result would not really be human readable. Hence, you would need a source map file that has information about the bundled file for debugging, error reporting, e.t.c. It would only be needed on Sentry, and shouldn’t be in the CodePush bundle.

If you are interested, you can read more about source maps here.

We can specify the sourcemap destination by using the flag — sourcemap-output into your existing appcenter codepush CLI command. We can specify the sourcemap output to be in another location and upload this CodePush bundle to AppCenter. After that is done, we move the sourcemap back to the CodePush folder and upload the artifact onto Sentry.

After this step was completed, our CodePush bundles’ sizes became:

  • iOS: 22.4–8.5 = 13.9 MB (37.9 % reduction compared to the initial bundle size of 22.4 MB)
  • Android: 22.4–6.6 = 15.8 MB (29.5 % reduction compared to the initial bundle size of 22.4 MB)

🚶| Migrating Our Assets From CodePush Bundle To Native

There are 2 ways to store assets in a React Native app: native and bundled.

Native assets are bundled together with the rest of the native code that the user downloads when they first visit the app store to download your application. They are also downloaded whenever the user updates the application from the app store.

On the other hand, bundled assets live with the JS Bundles that gets sent OTA via CodePush.

In our case, we realised that our assets are mostly unchanged. Sending them OTA every-time would cause the JS Bundle to bloat up unnecessarily. By moving them to the Native side, the user only has to download all of the assets once when they are downloading the app from the app store.

To migrate assets from Bundled to Native, we made use of the handy package react-native-assets

However, we ran into the issue of duplicating filenames. This is due to the way that we were handling theming.

// Folder structure
assets
|
|__light
|__myImage.png
|
|__dark
|__myImage.png

By having images of the same name in different theme folders, and a script to generate a map based on this folder structure, we were able to easily switch the asset being used as user changes their theming.

// Generated asset file

const assets = {
light: {
myImage: require('../path/to/myImage.png'); // same filename, different folders
},
dark: {
myImage: require('../path/to/myImage.png'); // same filename, different folders
}
}

// Anywhere else that uses the asset

// Hook that handles theming logic
const { assets } = useAssets();

// Doesn't need to know what the current theme is
<Image source={assets.myImage} />

However, when we link these assets, the structure was flattened and we ran into duplicating asset error. This resulted in the app not being bundled on iOS and the app being bundled without any asset on Android.

To combat this issue, we wrote a handy script to rename all assets to have a .theme suffix (e.g myImage.light.png ). We can then migrate with ease, while still maintaining the same data structure.

// Generated asset file

const assets = {
light: {
myImage:
Platform.OS === 'ios'
// According to the doc, no extension is needed for iOS. However,
// not including the extension got our images to not show up
? 'myImage.light.png'

// After linking, our images was generated under the asset/custom/ folder.
// Hence, we reference the custom folder in our path. If this is not the
// case for you (under a different folder name/other levels of nesting)
// then you should edit acordingly
: 'asset:/custom/myImage.light.png'
},
dark: {
myImage:
Platform.OS === 'ios'
? 'myImage.dark.png'
: 'asset:/custom/myImage.dark.png' }
}

After this step was completed, our CodePush bundles no long send assets OTA. As a result, the bundles’ sizes became:

  • iOS: 13.9–9.9 = 4.0 MB (82.1% reduction compared to the initial bundle size of 22.4 MB)
  • Android: 15.8–9.8 = 6.0 MB (73.2% reduction compared to the initial bundle size of 22.4 MB)

🛄 | Asset Handling Strategy

Pretty good progress! However, there is now a new problem:

“We can’t add new assets without requiring a new AppStore release”

Since now all assets are on the native side, adding new assets requires further linking and a new AppStore release, which takes a long time and results in bad UX for our users. Not ideal.

Thus, we opted for a hybrid solution. For new assets, we would add them to the CodePush bundle. Whenever there is a new release being scheduled, we would migrate them over to the Native side (with a script, of course).

This way, we can still have a relatively small CodePush bundle between each releases, and reduce them to the minimum whenever we can, without affecting the users’ experience.

To allow both native and bundled assets in the same app, without the user of our useAssets hook knowing which asset is from the Native side and which is from the Bundled side, we generate 2 separate asset declaration files and combine them into 1 file that has the same data structure

// Generated NativeAssets.generated.ts

const assets = {
light: {
myImage:
Platform.OS === 'ios'
? 'myImage.light.png'
: 'asset:/custom/myImage.light.png'
},
dark: {
myImage:
Platform.OS === 'ios'
? 'myImage.dark.png'
: 'asset:/custom/myImage.dark.png'
}
}

// Generated BundledAssets.generated.ts file

const assets = {
light: {
myImage2: require('../path/to/myImage2.png');
},
dark: {
myImage2: require('../path/to/myImage2.png');
}
}

// Assets.ts to reconcile them

const assets = {
light: {
...nativeAssets.light,
...bundledAssets.light
},
dark: {
...nativeAssets.dark,
...bundledAssets.dark
}
}

// Anywhere else that uses the asset

// Hook that handles theming logic
const { assets } = useAssets();

// Doesn't need to know what the current theme is,
// nor where does the asset come from
<Image source={assets.myImage} />

😱 | Summary

All in all, we have managed to greatly reduce our CodePush Bundle, while not compromising on our UX and DX!

iOS: 22.4 MB => 4.0 MB ~ 82.1%

Android: 22.4 MB => 6.0 MB ~ 73.2%

As a result, downloading new CodePush bundles on both iOS and Android only takes about 10 seconds to download our bundles on a regular 4G network. Much better UX 😁

--

--