Every Byte Matters: Reducing React Native Bundle Size


TL;DR
- React Native JS bundles include every installed library — even code paths the user never hits
react-native-bundle-visualizershows exactly which libraries are eating your bundle before you write a single fix- Hermes compiles JS to bytecode at build time: ~20% smaller bundle, no runtime parse step, faster startup
inlineRequiresin Metro defers module evaluation to first use — eliminates evaluation cost for screens never visited at launch- Replacing
momentwithdate-fnsand cherry-picking fromlodashremoved ~30% of our JS bundle alone - Switching to Android App Bundles (AAB) with R8 minification cuts final APK size by 15–35% by delivering only device-relevant resources
For every 6MB increase in app size, Play Store install conversion drops roughly 1%. Not a dramatic cliff — a quiet, continuous drain. Most React Native apps ship 3–4x more code than they need to, because the default build includes everything: every installed library, every screen, every utility function, whether the user will ever reach it or not.
A 200-screen app with 80 npm dependencies ships code for all 80 and all 200 on every launch. The bundle size problem is structural, not accidental.
This post documents what we did to fix it: measure first, cut the biggest offenders, repeat. No rewrites. No architectural changes. Each step is independently verifiable.
Measure Before You Cut Anything
The worst version of bundle optimization is guessing. We spent a week on image compression before running a bundle visualizer and realizing 80% of our JS bundle was three JavaScript libraries — not assets. The visualizer should be the first thing you run.
react-native-bundle-visualizer
Callstack's bundle visualizer generates a treemap of every module in your Metro output.
npx react-native-bundle-visualizer
No installation needed. It bundles your app using Metro, then opens a browser with a treemap showing each module's contribution to total bundle size. Libraries consuming more than 5% of the total are your priority targets — fix those first.
Expo Atlas (Expo SDK 50+)
If you're on Expo, Atlas is built in:
EXPO_UNSTABLE_ATLAS=true npx expo export
The Atlas output shows a module tree broken down by dependency depth, which makes it easier to trace why a heavy library ended up in the bundle at all.
What you're looking for in both tools:
- Any single library taking more than 5% of total JS bundle size
- Libraries you're importing in full but only using one or two functions from
- Packages in
node_modulesthat your code no longer calls at all
Run the visualizer before and after each optimization step. Don't batch changes and then measure once at the end — you won't know what actually worked.
Verify Hermes Is Enabled
Hermes is the highest-leverage single change for bundle size and startup, on both platforms. React Native 0.70 and later ship with Hermes enabled by default — if your project was created on 0.70+, you already have it. The section below is for older projects that may still be running JavaScriptCore.
Without Hermes, your app ships raw JavaScript. The runtime (JavaScriptCore on iOS, V8 on Android) parses and JIT-compiles it on every cold start. Hermes replaces that with build-time compilation: your bundle is transformed into Hermes bytecode (.hbc) during the build step. The device loads pre-compiled bytecode — no parse step, no JIT warmup.
The .hbc format is also more compact than the equivalent raw JavaScript. Expect ~15–25% reduction in JS bundle size from this step alone.
How to check if Hermes is already active
On a running debug build, open the Metro dev menu (shake the device or Cmd+D on iOS simulator) and check the bottom bar. It shows the JS engine name. If it says "Hermes," you're good — skip to the next section.
Programmatically:
const isHermes = () => !!global.HermesInternal;
console.log('Hermes enabled:', isHermes());
Enabling Hermes on older projects (pre-0.70)
If HermesInternal is undefined, Hermes needs to be enabled manually:
Android — android/app/build.gradle:
project.ext.react = [
enableHermes: true
]
iOS — ios/Podfile:
use_react_native!(
:path => config[:reactNativePath],
:hermes_enabled => true
)
After changing either file, do a clean rebuild:
# Android
cd android && ./gradlew clean && cd ..
# iOS
cd ios && pod install && cd ..
npx react-native run-ios --configuration Release
One constraint worth knowing: Hermes bytecode is incompatible with RAM bundles. If you're currently using RAM bundles, disable them when enabling Hermes — Hermes achieves the same goal (faster startup by reducing parse cost) and does it better. There's no scenario where RAM bundles outperform Hermes.
Defer Module Evaluation with inlineRequires
Enabling Hermes removes the parse cost. The remaining startup cost is evaluation cost: all those modules still execute, and many shouldn't execute until they're needed.
Metro's inlineRequires transforms your bundle so that require() calls are moved from module load time to first call site. A charting library imported at the top of a screen file no longer runs its initialization at launch — it runs when the user first visits that screen.
metro.config.js:
const { getDefaultConfig } = require('@react-native/metro-config');
const config = getDefaultConfig(__dirname);
config.transformer.getTransformOptions = async () => ({
transform: {
experimentalImportSupport: false,
inlineRequires: {
blockList: {
// Modules with startup side effects — must evaluate at launch
[require.resolve('./src/services/analytics.js')]: true,
[require.resolve('./src/services/errorReporting.js')]: true,
},
},
},
});
module.exports = config;
The blockList is not optional. Any module that registers global error handlers, sets device context, or has side effects beyond its exports must be listed here. Without the blocklist, those handlers don't register until the module is first imported somewhere in the component tree — which in some code paths may never happen.
On our app, this dropped modules evaluated at startup from ~360 to ~55. The rest initialize only when their screen is first visited.
inlineRequires doesn't reduce bundle size — it reduces how much of the bundle runs at launch. The distinction matters: the bytes are still downloaded. But evaluation cost has a direct impact on time-to-interactive, and on a heavy app it can exceed the parse cost that Hermes removes.
Audit and Replace Heavy Libraries
After the bundle visualizer, you have a ranked list of what's taking space. The fix is usually one of three things: replace the library, cherry-pick from it, or remove it entirely.
Replace moment with date-fns
moment is the canonical example of a library that ships more than most apps need. The full moment package with locale data is ~300KB gzipped, ~750KB uncompressed. date-fns provides the same formatting and manipulation surface area with tree-shakable modules.
npm uninstall moment
npm install date-fns
// Before — imports the entire moment bundle
import moment from 'moment';
const formatted = moment(date).format('MMM DD, YYYY');
// After — imports only the functions used
import { format } from 'date-fns';
const formatted = format(date, 'MMM dd, yyyy');
The saving is proportional to how much of moment you were pulling in. If you used moment for three formatting patterns, you just removed ~700KB from your JS bundle.
Cherry-pick from lodash
Full lodash imported as a default import or via destructuring does not tree-shake in Metro the way it does in webpack:
// This does NOT tree-shake in Metro — pulls in the full lodash build
import { debounce } from 'lodash';
// This tree-shakes correctly — imports only the debounce module
import debounce from 'lodash/debounce';
The difference matters. Lodash is ~530KB uncompressed. If you're using five functions and importing the full build, you're shipping 525KB of dead code.
Go through every lodash import and convert to direct module paths. Or replace with smaller, single-purpose alternatives: lodash/debounce → just-debounce-it, lodash/cloneDeep → klona.
Find unused dependencies
npx depcheck
depcheck scans your source files and identifies packages in package.json that no code actually imports. On most production apps this surfaces 5–10 packages that were installed for a feature that was later removed or rewritten.
Remove them:
npm uninstall <package-name>
Unused packages don't necessarily contribute to bundle size — Metro will exclude modules with no import path from the root. But they add installation weight, security surface area, and can show up in the bundle if Metro's import resolution picks them up transitively.
Android App Bundle and R8 Minification
This section is Android-specific. iOS has no equivalent manual step — the App Store handles app thinning automatically through bitcode recompilation and device-specific slicing.
Switch to Android App Bundle (AAB)
A universal APK includes native libraries compiled for every CPU architecture (arm64-v8a, armeabi-v7a, x86, x86_64) and image resources at every screen density. A device needs exactly one of each. The rest is shipped and discarded.
Android App Bundles let Google Play handle the splitting. It serves each device only the ABIs and resources it actually needs.
To build an AAB:
cd android && ./gradlew bundleRelease
The output is android/app/build/outputs/bundle/release/app-release.aab. Upload this to Play Console instead of the APK.
Expected reduction: 15–35% compared to the universal APK, depending on how many ABI targets your native dependencies include.
One operational caveat: AAB files cannot be directly installed on a device with adb install. Your internal QA distribution needs a separate APK build:
cd android && ./gradlew assembleRelease
Keep both build targets in your release pipeline — AAB for the Play Store, APK for internal distribution. Switching to AAB without a separate APK path breaks local beta installs.
Enable R8 Minification
R8 is Android's default code shrinker (replacing ProGuard since AGP 3.4). It removes unused Java and Kotlin classes, obfuscates symbol names, and optimizes bytecode.
android/app/build.gradle:
android {
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}
shrinkResources true removes unused Android resource files (drawables, layouts, strings). minifyEnabled true activates R8 for code shrinking and obfuscation.
R8 is aggressive. You will likely need to add keep rules for classes that are referenced via reflection:
android/app/proguard-rules.pro:
# React Native core
-keep class com.facebook.react.** { *; }
-keep class com.facebook.hermes.** { *; }
# Your native module classes
-keep class com.yourapp.modules.** { *; }
Test a release build thoroughly after enabling R8. Crashes caused by over-aggressive shrinking show up as ClassNotFoundException or NoSuchMethodException at runtime — not at build time.
Asset and Font Optimization
JavaScript libraries dominate most React Native bundles. But assets are worth a pass, particularly on apps with large image sets or multiple custom fonts.
Convert PNG to WebP
WebP provides ~25–30% smaller file sizes compared to PNG at equivalent visual quality. Android has native WebP support since API 18. iOS has supported it since iOS 14.
Batch convert with sharp:
npx sharp-cli --input "src/assets/images/*.png" --output "src/assets/images/" --format webp
Update all image references to .webp. On a large app with hundreds of local images, this step can meaningfully reduce installation size — even if JS bundle size is your primary target.
Reduce Font Weights
Custom fonts are commonly over-shipped. Including Thin, Light, Regular, Medium, SemiBold, Bold, ExtraBold, and Black variants ships 8 font files when most interfaces use 2–3.
Audit which weights your design system actually uses. Remove the rest from the bundle. For Latin-script apps with heavy font files, subset to the characters in use:
pip install fonttools
pyftsubset Inter-Regular.ttf --unicodes="U+0020-007E,U+00A0-00FF" --output-file=Inter-Regular-subset.ttf
A full Inter Regular file is ~320KB. A Latin-subsetting reduces it to ~45KB.
Measured Impact by Technique
| Technique | JS Bundle Reduction | APK/IPA Reduction | Notes |
|---|---|---|---|
| Hermes | ~20% | ~10–15% | Higher on Android; iOS JIT is already fast |
| inlineRequires | 0% (size) | 0% (size) | Reduces evaluated modules at startup, not bundle size |
| Library replacement | ~25–35% | ~15–25% | Depends entirely on which libraries you're replacing |
| AAB + R8 (Android only) | — | ~15–35% | Device-specific resource delivery |
Bundle bloat accumulates silently. Libraries get added, features get removed, and the dependencies stay. A 200-screen app can easily ship 400KB of a date formatting library it no longer uses and 600KB of a charting library wired to a screen that's been behind a feature flag for eight months.
The sequence that works: run the visualizer first, target modules above 5% of total size, measure after each change, don't move to the next technique until you've confirmed the last one landed. Hermes and library replacement address JS bundle size. AAB with R8 addresses the final APK. They're complementary — run them in order.
The bytes were always there. These changes just make them visible first.





