Bar chart from our own builds of one Expo SDK 58 app: Play download on an arm64 phone 16.10 MB with JSC and no R8, 14.16 MB with Hermes, 8.51 MB with Hermes plus R8 and resource shrinking; thinned iPhone .ipa 8.09 MB with prebuilt React Native, 5.90 MB with React Native compiled from source

Most advice on React Native app size is a list of switches: turn on Hermes, turn on R8, split by ABI. What it rarely says is how many megabytes each switch is worth, or which number to look at. So we built the same small app again and again, changing one setting at a time, and measured what a phone actually downloads and installs. On Android the download went from 16.10 MB to 8.51 MB. On iPhone it went from 8.09 MB to 5.90 MB, with a trade-off most teams don't know they're making.

The app is a one-screen Expo app (Expo SDK 58.0.17, React Native 0.88.0, React 19.3, New Architecture on), the same JavaScript in every build. Android: AGP 9.2.1, Gradle 9.4.1, NDK 27.1, Kotlin 2.2, minSdk 24, built in release mode and measured with bundletool 1.18.1. iOS: Release archives with Xcode 27.0 on a Mac mini, exported ad hoc with app thinning for all variants. All builds were made on 25 and 26 October 2026. MB in this article is 1,048,576 bytes (the exact bytes are in the tables); where Apple's App Thinning report is quoted, its MB is 1,000,000 bytes and we say so.

Android: the per-step table

Each row changes one thing from the row above. Rows 1 to 3 are Android App Bundles (AAB), the format Play requires; row 4 is a plain APK built for one ABI, the kind you send to testers by link.

text
step                               AAB        universal APK   Play download,   installed on
                                                              arm64 phone      arm64 phone
1  JSC, no R8                      54.69 MB   95.43 MB        16.10 MB         45.30 MB
2  Hermes, no R8                   46.29 MB   70.12 MB        14.16 MB         38.57 MB
3  Hermes + R8 + shrinkResources   43.20 MB   63.72 MB         8.51 MB         21.98 MB
4  + ABI split, arm64-only APK        -       19.99 MB           -             ~20 MB

bytes: Play download 16,882,802 / 14,844,781 / 8,923,511
       arm64 APK (step 4) 20,962,650

"Play download" is what bundletool's get-size total reports for an arm64 phone (Android 15, 420 dpi, English): the compressed bytes Google Play sends that device. "Installed" is the sum of the split APKs Play would put on it (the base, the arm64 native code, English strings and xxhdpi resources). The universal APK holds all four ABIs, which is what you get from assembleRelease with no splits.

Two numbers to take away. Hermes plus R8 roughly halves what a user downloads from Play (16.10 to 8.51 MB, -47%) and the space the app takes on the phone (45.30 to 21.98 MB, -51%). And if you hand out APKs yourself, building one per ABI cuts the file a tester downloads from 63.72 MB to 19.99 MB (-69%).

Don't judge size by the AAB

The AAB on your disk is the wrong number to watch. About a third of each bundle is BUNDLE-METADATA that no user ever downloads: native debug symbols for all four ABIs (15.1 MB stored, 52 MB raw) and, once R8 is on, its 36 MB mapping file. Play keeps both so crash reports can be symbolicated and deobfuscated. That is why turning R8 on made the AAB only 7% smaller while the Play download fell 40%.

Step 1 to 2: what Hermes changes, and what it doesn't

Switching from JavaScriptCore (JSC) to Hermes saved 1.94 MB of download and 6.73 MB of installed size. Almost all of it is the engine library, not your code:

text
native engine, arm64-v8a              bytes
libjsc.so (JSC build)                 10,173,344
libhermesvm.so + libhermestooling.so   2,845,352   (2,704,136 + 141,216)

the same app's JavaScript             bytes       gzip -9
minified JS (JSC build's APK)         1,096,375   274,870
Hermes bytecode (Hermes build's APK)  1,253,908   555,358

The bytecode is 14% bigger than the minified JS it replaces and compresses about half as well, so for an app with a large bundle Hermes gives some of its saving back. The library difference (about 7 million bytes per ABI) is fixed, so Hermes wins on size for any normal app, but the reason to use it is start-up time: the phone runs precompiled bytecode instead of parsing JavaScript at launch. We measured size only, not start-up.

If you are on a current Expo or React Native template you already have Hermes. JSC is now a community package: in our SDK 58 build, setting jsEngine to "jsc" in app.json no longer changed the Android build; we had to set hermesEnabled=false in gradle.properties and add the io.github.react-native-community:jsc-android dependency. Nobody needs to go back; it's here as the baseline.

Step 2 to 3: R8 and resource shrinking

R8 removes Java and Kotlin classes and methods the app never calls, then renames and optimizes what's left. resource shrinking then drops drawables and layouts nothing references. In our build:

text
                         step 2 (no R8)   step 3 (R8 + shrinkResources)
dex (classes*.dex), raw  20.15 MB          4.23 MB    (-79%)
res + resources, raw      2.99 MB          1.38 MB    (-54%)
Play download, arm64     14.16 MB          8.51 MB    (-5.65 MB)

That 5.65 MB per download is the single biggest step in this article, bigger than Hermes. Expo SDK 58's template already turns R8 on; resource shrinking stays off until you ask for it. In a bare React Native project, or to set both explicitly, it is two lines in android/gradle.properties for an Expo app (the prebuild reads them) or the release block in android/app/build.gradle:

groovy
# android/gradle.properties (Expo SDK 58 prebuild)
android.enableMinifyInReleaseBuilds=true
android.enableShrinkResourcesInReleaseBuilds=true

// android/app/build.gradle (bare React Native)
android {
    buildTypes {
        release {
            minifyEnabled true
            shrinkResources true
            proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
        }
    }
}

What it costs: R8 can't see code that's only reached by reflection, so a library or your own native module that looks classes up by name can crash in release builds while debug builds work. Most React Native libraries ship their own consumer keep rules; our sample built with R8 and no extra rules (we measured size, not behaviour on a device, so test your release build before shipping it). When something does break, the release crash names the missing class; add a keep rule for it in proguard-rules.pro rather than turning R8 off:

text
# android/app/proguard-rules.pro
# keep classes a library creates by reflection (example: JSON models)
-keep class com.example.app.models.** { *; }
# keep a native module's methods that are only called from JNI or JS
-keepclassmembers class com.example.app.MyModule { *; }

The other cost is the mapping file: crash stack traces from an R8 build are obfuscated until they're matched with the mapping.txt from that exact build. In an AAB it travels inside the bundle (that's the 36 MB above) and Play uses it automatically; for APKs you send yourself, keep each build's mapping.txt next to the APK. Teams that switched R8 off after one reflection crash are paying about 5.6 MB on every install of an app like ours.

Step 3 to 4: ABI splits, and when they matter

Native code is the largest part of a React Native APK. With Hermes, each ABI carries about 15.5 MB of raw .so files (libreactnative.so alone is 7.05 MB on arm64), and a universal APK carries four sets: arm64-v8a, armeabi-v7a, x86_64 and x86.

On Google Play you don't need ABI splits. The AAB already lets Play send each phone only its own ABI, which is why our Play download for an arm64 phone is 8.51 MB while the universal APK is 63.72 MB. ABI splits matter when you distribute APKs yourself: install links for testers, a sideloaded build, another store. There, one arm64-v8a APK was 19.99 MB against 63.72 MB for the universal one.

groovy
// android/app/build.gradle
android {
    splits {
        abi {
            enable true
            reset()
            include "arm64-v8a"            // add "armeabi-v7a" for old 32-bit phones
            universalApk false
        }
    }
}

# or, for a quick tester build without touching build.gradle:
./gradlew assembleRelease -PreactNativeArchitectures=arm64-v8a

Three caveats. An arm64-only APK won't install on an old 32-bit phone or an x86_64 emulator, so keep armeabi-v7a in the list if your testers have older devices. If you publish per-ABI APKs to a store that isn't Play, each needs its own versionCode (Android's split docs show the pattern). And the 19.99 MB arm64 APK is still bigger than the 8.51 MB Play download, because in an APK the .so files are stored uncompressed and page-aligned so Android can load them straight from the file; Play compresses them for the transfer.

Since native libraries come up: that page alignment is also what Android's 16 KB page-size rule checks. Our 16 KB check tool reads every .so in an APK and tells you whether it's ready, and the 16 KB article covers the 1 February 2027 deadline.

iOS: prebuilt React Native vs compiled from source

On iOS there's no R8 and no ABI split: App Store builds are arm64 only, and bitcode has been gone since Xcode 14. The step that moved the number was how React Native itself is linked.

text
step                                universal .ipa   iPhone .ipa   App Thinning report,      dSYMs
                                                     (thinned)     iPhone: download /
                                                                   installed (Apple MB)
1  Hermes, prebuilt RN (SDK 58      8.68 MB          8.09 MB       8.5 MB / 29.8 MB          ~27 MB
   default)
3  Hermes, RN compiled from source  6.49 MB          5.90 MB       6.2 MB / 17.2 MB          ~128 MB

bytes: universal 9,101,006 / 6,805,889; iPhone variant 8,486,166 / 6,191,052

Expo SDK 58 installs React Native and its own modules as prebuilt dynamic frameworks by default, which makes clean builds much faster. The catch is that a dynamic framework keeps every symbol, because the linker can't know which ones your app will call. Compiled from source, the same code becomes static libraries linked into the app binary, and the Release build's dead-code stripping drops whatever the app never calls:

text
inside the .ipa, raw bytes            prebuilt RN    RN from source
app binary (Hello)                     446,416       7,360,848
React.framework                     12,505,520       (linked in)
ExpoModulesCore.framework            3,983,482       (linked in)
ReactNativeDependencies.framework    1,272,921       (linked in)
ExpoFileSystem, ExpoFont, Worklets   1,774,387       (linked in)
binary + those frameworks           19,982,726       7,360,848
hermesvm.framework                   6,408,161       6,408,161  (dynamic in both)
main.jsbundle (Hermes bytecode)      1,550,944       1,550,951

Result: the iPhone download falls 27% (8.5 to 6.2 MB in Apple's report) and the installed size 42% (29.8 to 17.2 MB). To build that way, set two variables when you install pods:

bash
cd ios
RCT_USE_PREBUILT_RNCORE=0 RCT_USE_RN_DEP=0 pod install
# then archive as usual (Xcode, or xcodebuild ... -configuration Release archive)

What it costs: every clean build now compiles React Native's C++ and Objective-C (in our case the archive went from 2m 22s to 2m 45s with a warm cache; a cold CI machine pays much more), and the dSYMs you upload for crash reports grow from about 27 MB to about 128 MB because React Native's own debug info is now yours. For a release pipeline that builds a few times a day that's a fair price for 2.3 MB off every download; for preview builds on every pull request, keep the prebuilt default.

JSC on iOS and app thinning

We wanted a JSC row for iOS too and couldn't build one: with Expo SDK 58, switching to the community JavaScriptCore package fails in Expo's own module layer (ExpoModulesJSI includes hermes/hermes.h). So there is no JSC IPA number here, and we won't guess one. What we can say from build 1: a JSC build would drop hermesvm.framework (1.74 MB compressed in the .ipa) because iOS ships JavaScriptCore with the system, and would carry plain JS instead of bytecode.

App thinning did very little for this app. The iPhone and iPad variants were the same size (8,486,166 and 8,486,306 bytes); thinning only cut the asset catalog from 1,261,912 to 639,896 bytes by keeping one set of icon renditions. With arm64-only code and no bitcode, thinning mostly helps apps with lots of images in asset catalogs.

The size a user sees, worked through

Take step 1 against step 3 on Android. Each arm64 install from Play downloads 16.10 - 8.51 = 7.59 MB less. At 10,000 installs a month that's 75,900 MB, about 74 GB of your users' data not spent, and each phone keeps 23.32 MB more free space (45.30 - 21.98 MB installed). For testers installing from a link, the move from the universal APK (63.72 MB) to the arm64 APK (19.99 MB) saves 43.73 MB per tester per build: a team sending two builds a day to 20 testers stops pushing about 1.7 GB a day through their phones.

On iPhone the same sums are smaller, because the App Store already sends only the thinned variant: 8.5 to 6.2 MB per download in Apple's figures, 2.3 MB saved, for the cost of slower clean builds.

The checklist, in the order that pays

text
1  Android: R8 + shrinkResources on        -5.65 MB per Play download   needs keep rules if a library uses reflection
2  Android: Hermes (template default)       -1.94 MB per Play download   start-up gain too; bytecode is bigger than JS
3  Android: per-ABI APKs for links/stores  -43.73 MB per tester APK      no effect on Play (the AAB already splits)
4  iOS: RN compiled from source             -2.3 MB per iPhone download  slower clean builds, bigger dSYM uploads
5  iOS: app thinning                        -0.6 MB here (icons only)    on by default for App Store and TestFlight

Measure your own app before and after each change the same way: bundletool build-apks on your AAB, then get-size total with a device spec for the phone you care about, and on iOS the App Thinning Size Report that xcodebuild -exportArchive writes when thinning is on. The AAB and the universal .ipa on your disk will mislead you; the per-device numbers won't.

bash
java -jar bundletool-all-1.18.1.jar build-apks --bundle=app-release.aab --output=app.apks
java -jar bundletool-all-1.18.1.jar get-size total --apks=app.apks --device-spec=arm64-phone.json
java -jar bundletool-all-1.18.1.jar get-size total --apks=app.apks --dimensions=ABI

# arm64-phone.json
{"supportedAbis":["arm64-v8a","armeabi-v7a","armeabi"],"supportedLocales":["en-US"],"screenDensity":420,"sdkVersion":35}

aipps.dev builds React Native, Android and iOS apps and shows the app file and its size on every build, so a change that adds megabytes shows up in the next build, not in a store review. Every build gets a QR install page for testers.

See aipps.dev

Related: what iOS and Android builds cost on CI in 2026, and installing a test build from a QR code, where an arm64-only APK makes the download quicker for testers.

Sources (read 26 October 2026): Android Developers, "Shrink, obfuscate, and optimize your app" (developer.android.com/build/shrink-code), "Build multiple APKs" (developer.android.com/build/configure-apk-splits: publish an app bundle to Play, which serves each device optimized APKs; universalApk), "About Android App Bundles" (developer.android.com/guide/app-bundle) and bundletool (developer.android.com/tools/bundletool); React Native docs, "Using Hermes" (reactnative.dev/docs/hermes); Apple, "Reducing your app's size" (developer.apple.com/documentation/xcode/reducing-your-app-s-size) and the Xcode 14 release notes (bitcode deprecated). All sizes are from our own builds of one sample app on 25 and 26 October 2026; your app's numbers will differ with its native modules, assets and bundle size, but the order of the steps holds.