App Size Bloat in React Native: Auditing Your Bundle Before Apple Notices
React Native apps quietly balloon past 100MB and tank install rates. Here's how we audit bundle size, hunt down the real culprits, and ship a leaner binary without rewriting in Swift.
A client pinged us last quarter: their React Native app had crept past 180MB on Android and install conversion was sliding. Nobody had "added" anything obvious — features shipped, dependencies got bumped, and the binary quietly doubled over eighteen months. This is the most common performance problem we see in mature RN codebases, and it's almost always fixable without going native.
Here's the audit process we run, what tends to be hiding in there, and the trims that actually matter in 2026.
Why size matters more than the dashboards suggest
Apple and Google both publish guidance that install conversion drops as binary size grows, and our own client data lines up with that — somewhere past the 150MB mark on Android, you start seeing meaningful drop-off on cellular installs. iOS has a softer curve because of App Store on-demand resources and slicing, but a 250MB+ IPA still hurts on older devices with tight storage.
There are also indirect costs:
- OTA update payloads scale with your JS bundle. A bloated bundle means slower EAS Update downloads on cold launches.
- App review gets pickier. Apple has started flagging unused frameworks and dead architectures in reviewer notes.
- Cold start time correlates with binary size on Android because of dex loading and class verification.
Size isn't vanity. It's a conversion metric.
What's actually in your binary
Before you optimise anything, look at what shipped. For Android, the Android App Bundle (AAB) is your source of truth. For iOS, it's the IPA plus the per-architecture thinning.
Android: cracking open the AAB
The quickest way is bundletool plus a manual unzip:
# Build a release AAB
cd android && ./gradlew bundleRelease
# Get per-device APK sizes
bundletool build-apks \
--bundle=app/build/outputs/bundle/release/app-release.aab \
--output=app.apks \
--mode=universal
# Inspect contents
unzip -l app/build/outputs/bundle/release/app-release.aab | sort -k1 -n
You're looking for four buckets:
lib/— native.sofiles. Hermes, JSC, FBJNI, and every native module you've installed.assets/— your JS bundle, fonts, images bundled at build time.res/— drawables, layouts, and anything from native modules.classes*.dex— compiled Java/Kotlin code, including every transitive dependency.
In our experience, lib/ is usually 40–60% of an unoptimised RN binary. That's where you start.
iOS: reading the IPA
Use Xcode's Organizer → "Show Package Contents" on the archive, or unzip the IPA directly. The interesting paths:
Payload/YourApp.app/Frameworks/— every dynamic framework. Hermes alone is ~8MB per architecture.Payload/YourApp.app/main.jsbundle— your JS bundle.Payload/YourApp.app/Assets.car— compiled asset catalogue.
Apple's App Store Connect also gives you an "App Size Report" under your build, broken down by device class. Use it. It's the closest thing to ground truth for what users actually download.
The usual suspects
After running this audit on a few dozen client apps, the bloat sources are remarkably consistent.
Duplicate native architectures
If you haven't enabled ABI splits or App Bundle on Android, you're shipping arm64-v8a, armeabi-v7a, x86, and x86_64 to every device. That's roughly 4x your native code footprint. Fix in android/app/build.gradle:
android {
splits {
abi {
enable true
reset()
include "armeabi-v7a", "arm64-v8a"
universalApk false
}
}
}
If you publish via AAB (which you must, since August 2021), Play handles this automatically — but only if you haven't manually disabled it. Double-check.
Both Hermes and JSC shipped
We see this more often than you'd expect. A native module pulls in JSC as a transitive dependency, and now you're shipping two JavaScript engines. On Android, check android/app/build/intermediates/merged_native_libs/release/out/lib/arm64-v8a/ — if you see both libhermes.so and libjsc.so, something is forcing JSC.
Usually it's an outdated module that hasn't been updated for Hermes-only builds. npm ls jsc-android will surface it.
Fonts you don't use
The react-native-vector-icons family is the classic offender. Importing it the default way bundles every font file — FontAwesome, MaterialIcons, Ionicons, the lot. That's 5–10MB of fonts for the three icons you actually render.
With Expo, use @expo/vector-icons and only import the specific font families. With bare RN, configure react-native.config.js to copy only what you need:
module.exports = {
assets: ['./assets/fonts/MaterialIcons.ttf'],
};
Images that should be remote
Every PNG you require() ships in the binary. Marketing illustrations, onboarding hero images, achievement badges — these almost always belong on a CDN, loaded on demand. The rule we use: if it's not needed in the first 5 seconds of cold launch, it shouldn't be in the bundle.
For the assets that must ship, run them through a real compressor (pngquant, oxipng, or Squoosh) before committing. RN's bundler does not optimise images for you.
Dead native modules
Every react-native-* package you've ever installed and stopped using is probably still in your package.json, still being autolinked, still bloating your .so files and dex. Run:
npx depcheck
npx react-native config
The second command lists what's actually autolinked. Cross-reference with what your code imports.
Hermes bytecode and the JS bundle
Your JS bundle is smaller than you think — but it still matters because it's also what flies over the wire on every OTA update.
Hermes ships precompiled bytecode (.hbc) instead of raw JS, which is faster to parse but slightly larger on disk. That's a fair tradeoff for cold start. What you can control:
- Tree shaking: make sure you're not importing entire libraries.
import _ from 'lodash'adds ~70KB.import debounce from 'lodash/debounce'adds 2KB. - Moment.js: still showing up in 2026. Replace with
date-fnsordayjsand you'll save 60–200KB depending on locales. - Locale data:
moment-timezoneand full ICU bundles can add megabytes. Most apps need maybe three locales.
Generate a bundle visualiser to actually see what's in there:
npx react-native bundle \
--platform android \
--dev false \
--entry-file index.js \
--bundle-output bundle.js \
--sourcemap-output bundle.map
npx react-native-bundle-visualizer
It opens a treemap in your browser. Spend ten minutes with it. You will find something embarrassing.
Expo-specific wins
If you're on Expo with EAS Build, a few things are worth knowing:
expo-assetand remote assets: assets referenced viaAsset.fromURIare downloaded at runtime, not bundled. Move large illustrations there.- Build profiles: your
eas.jsonproduction profile should be running release builds with Proguard/R8 on Android. Verify by inspecting the AAB; if class names are full strings likecom.yourcompany.yourapp.SomeClass, R8 isn't running. - Expo modules vs community modules: Expo's first-party modules tend to be better tree-shaken than equivalent community packages. If you have both
expo-imageand a separate image library, pick one.
One caveat: Expo's managed workflow includes a baseline of native modules whether you use them or not. The trim there is moving to a development build and removing what you don't need from app.json's plugins array.
Where Swift and Kotlin would actually help
We're React Native advocates, but be honest about the floor. A pure Swift app with no JS runtime starts around 10–15MB. A pure Kotlin app with Jetpack Compose starts around 8–12MB. A well-trimmed RN app realistically floors around 25–35MB on iOS and 20–30MB on Android.
That ~15MB delta is the cost of cross-platform. For most products it's worth it. If you're building something where every megabyte matters — emerging-market apps, super-light utilities, watchOS companions — that's where the native rewrite conversation actually makes sense. For everything else, the audit above will get you 80% of the way to native sizes.
Where we'd start
If your app is over 100MB on Android or 150MB on iOS and you've got a free afternoon:
- Run the bundle visualiser. Screenshot the treemap.
- Open your AAB and IPA. List the top 10 files by size.
npx depcheckandnpx react-native configto find dead modules.- Confirm you're shipping AAB, not universal APK, and that R8 is on.
- Audit
react-native-vector-iconsand anyrequire()'d images over 100KB.
That's usually enough to claw back 30–50% of the binary in a single sprint. The remaining wins take longer — replacing heavy dependencies, lazy-loading screens, moving assets to CDN — but they compound.
If you'd like a hand running this audit on your own app, that's the kind of work our mobile team does. Otherwise, the tools above are all free. Start with the treemap.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Deep Links in React Native 2026: Universal Links, App Links, and the Expo Router Trap
Universal Links and Android App Links still break in production more than they should. Here's a field guide for getting them right in Expo Router apps without surprising the App Review team.

Crash-Free Rate in React Native: Hitting 99.9% Without Going Native
How we pushed a React Native + Expo app from 99.2% to 99.9% crash-free sessions — what actually mattered, what was noise, and where Sentry, Hermes, and EAS fit in.

Background Tasks in React Native and Expo: What Actually Runs in 2026
Background execution on iOS and Android is a minefield of OEM quirks, OS budgets, and silent task kills. Here's what survives in production React Native and Expo apps in 2026 — and what to stop trying.
