Nobody files a ticket that says "our app is too big." What happens instead is that install conversion quietly sags in India and Brazil, a product manager notices that the Play Store listing says 180 MB, and somebody asks the consulting firm whether React Native is "just heavy." It usually is not. In almost every audit we run, the binary is big for three or four specific, fixable reasons.
This tutorial is the size-reduction pass we do on client apps: how to measure honestly, what actually moves the number, and what to leave alone.
Measure the number your users see, not the number your CI prints
The artifact size in your build log is nearly meaningless. What matters is the download size and the install size for a specific device.
- Android. Upload the AAB to the Play Console internal testing track and open App bundle explorer -> Downloads. It shows the delivered size per device configuration. Locally,
bundletool build-apks --connected-devicethenbundletool get-size total --apks app.apks --dimensions SDK,ABI,SCREEN_DENSITYgets you close without a release. - iOS. Upload to App Store Connect, wait for processing, and read the App Thinning Size Report in the build details. A universal
.ipafromeas buildis not what anyone downloads — thinning strips the other architectures and asset scales.
Write both numbers down with a date and the app version. Size work without a baseline turns into vibes within two sprints.
Then find out where the bytes are:
# Android: what is inside the bundle
unzip -l app-release.aab | sort -k1 -n -r | head -40
# iOS: unzip the ipa and sort the payload
unzip -q MyApp.ipa -d ipa && du -ah ipa/Payload/MyApp.app | sort -hr | head -40
For the JS side, generate a bundle and visualise it:
npx expo export --platform android --dump-sourcemap
npx react-native-bundle-visualizer # or: npx source-map-explorer bundle.js bundle.js.map
You are looking for a treemap, not a total. A 7 MB JS bundle made of 300 small modules is fine; a 7 MB bundle where one date library and one icon set are 3 MB of it is a ten-minute fix.
The four things that are almost always to blame
1. Icon fonts and icon sets shipped whole
react-native-vector-icons or @expo/vector-icons pulled in as a barrel import drags in every font family you never used. Import the single set you need, and if you use fewer than about thirty glyphs, ship SVGs through react-native-svg instead and delete the fonts.
2. Uncompressed raster assets
PNG hero images at 3x for screens that render at 160 pt wide are the single most common offender. Run everything through a compressor in CI, and prefer WebP on Android and HEIC/WebP on iOS:
npx @squoosh/cli --webp '{"quality":80}' assets/images/*.png
Large static images should not be in the binary at all. Move them behind a CDN and let expo-image cache them on first run.
3. Dependencies that brought friends
Check the heavyweights before you check anything else: moment (replace with date-fns or Intl), lodash imported as a whole (import { debounce } from 'lodash' pulls everything on some setups — use lodash.debounce or write the four lines), full aws-sdk v2, crypto-browserify polyfill chains, and analytics SDKs you are paying for but no longer read.
npx depcheck # unused deps
npx why-is-node-running # no, but seriously: npm ls <pkg> to find who pulled it in
4. Multiple architectures in one artifact
If you are still distributing a universal APK anywhere, you are shipping arm64 and armeabi-v7a to every device. The AAB handles this for Play; for any sideloaded or enterprise channel, split the ABIs.
Android: the settings that actually matter
Four changes, in rough order of payoff:
// android/app/build.gradle
android {
buildTypes {
release {
minifyEnabled true // R8 for Java/Kotlin
shrinkResources true // drops unreferenced resources
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
}
}
bundle {
language { enableSplit = true }
density { enableSplit = true }
abi { enableSplit = true }
}
}
Then, in gradle.properties, make sure you are only building the ABIs you distribute, and that Hermes is on (it has been the default for a long time, but brownfield apps and old templates still have it off):
reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86_64
hermesEnabled=true
newArchEnabled=true
Two cautions from experience. shrinkResources will eventually eat a drawable that is only referenced by name from JS or from a native module's reflection; keep a res/raw/keep.xml for those. And R8 rules shipped by third-party SDKs are not always complete — if you turn minification on for the first time, do a full regression pass on release builds before you ship, not a smoke test on debug.
If your app is genuinely large and feature-segmented, Play Feature Delivery can move rarely used native features into on-demand modules. In React Native apps this is usually only worth it when a single SDK (a scanner, an ML model, a map engine) is tens of megabytes and most users never open that screen.
iOS: thinning, bitcode is gone, and dead code
The iOS wins are narrower but real:
- Asset catalogs. Put images in
Assets.xcassetsrather than loose files so App Thinning can strip the scales a given device does not need. Deployment Postprocessing/ strip settings. ConfirmStrip Linked Product = Yes,Strip Style = All Symbols(orNon-Global Symbolsfor frameworks), andDead Code Stripping = Yesfor Release. These are correct in a fresh template and wrong in roughly half the brownfield projects we inherit.- Static vs dynamic frameworks.
use_frameworks! :linkage => :staticgenerally produces a smaller app than dynamic frameworks, which each carry their own load commands and signatures — but some SDKs (anduse_modular_headers!setups) require dynamic. Measure, do not assume. - Drop simulator slices. Any vendored
.xcframeworkor fat binary with x86_64 slices leaking into the release build is pure waste. - On-Demand Resources for large optional media, if you have an actual content library rather than a few images.
Bitcode is deprecated and removed; if you still have ENABLE_BITCODE settings or advice in your docs, delete them.
The JavaScript bundle
Hermes compiles your JS to bytecode, which means the bundle is no longer the main install-size lever it once was — but it still matters for OTA update payloads and cold start.
Things that work:
- Inline requires / lazy screens. With Expo Router or React Navigation, heavy screens should be
lazyso their modules are not evaluated at startup. This helps cold start more than size, which is usually the thing people actually care about anyway. expo-assetselective bundling. Do notrequire()an entire image directory.- Dev-only code elimination.
__DEV__branches get stripped, but only if the code is actually inside the branch. A top-levelimport DevMenu from './dev-menu'is in your release bundle no matter what guards its usage. - Metro's tree shaking. Newer Metro versions can do real dead-code elimination for ESM. Turn it on and verify in the treemap that it changed something; a lot of RN-ecosystem packages still ship CommonJS and will not shake.
Budget it, then defend it in CI
One-off cleanups regress. Put the number in the pipeline:
# .github/workflows/size.yml (sketch)
- run: npx expo export --platform android
- run: |
SIZE=$(du -sk dist | cut -f1)
echo "bundle_kb=$SIZE" >> "$GITHUB_STEP_SUMMARY"
test "$SIZE" -lt 9000 || { echo "JS bundle over budget"; exit 1; }
For the native artifact, compare the AAB size against the previous main build and fail (or just comment) on a delta above, say, 2%. The point is not the threshold. The point is that the engineer who added a 14 MB SDK finds out in the pull request instead of in a quarterly review.
What we tell clients not to bother with
- Micro-optimising the JS bundle by 200 KB while a 30 MB native SDK sits untouched.
- Removing a utility library to save 40 KB when the same PR costs two days of regression risk.
- Chasing the number below the store's cellular-download thresholds when your users are overwhelmingly on Wi-Fi and high-end devices. Check your own install analytics by device tier and country first.
- Aggressive R8 rules that you cannot explain. A crash in a release-only code path costs more than the megabyte saved.
A realistic result
On a typical mid-size app we inherit — a few dozen screens, maps, camera, analytics, a design system — the first pass usually looks like this: 6 to 12 MB from assets and icon fonts, 3 to 8 MB from enabling R8 and resource shrinking properly, 2 to 5 MB from removing or replacing two or three dependencies, and whatever App Thinning was already doing on iOS but nobody had measured. That is often a third of the download size, in under a week, with no feature work.
If your app has crossed a size threshold that is hurting installs and nobody on the team owns the number, this is exactly the kind of short, bounded engagement we do. Get in touch and we will start with the measurement pass.