Have you ever uploaded a Rork-generated app to TestFlight and been met with an App Store Connect warning that the binary is too large? I hit that exact wall while rebuilding one of my own wallpaper and wellness apps in Rork. The very first archive came in close to 180MB, and I had to get serious about slimming it down before shipping.
Here's the thing I wish I'd known sooner: most Rork-generated apps can be shrunk by 50–60% without rewriting a single line of feature code. What follows is the exact path I took, along with the numbers I measured at each step with xcrun and the Xcode Organizer.
Start by Pinpointing Where the Fat Lives
Without measuring, you'll "feel lighter" after each change and quit before the real wins. A typical Rork-generated app's binary can be broken into five chunks:
- The JavaScript bundle (
main.jsbundleor Hermes bytecode) - Images, fonts, and videos shipped as assets
- Native library binaries from Expo modules
- Localized resources (translation files and per-locale assets)
- Debug symbols (
dSYMand unstripped native code)
Here's how the split looked before and after on one of my wallpaper apps. The numbers come from the Xcode Organizer's App Size Report and xcrun measurements.
| Component | Before | After | Main lever |
|---|---|---|---|
| Image assets | 84 MB | 21 MB | WebP conversion, dropping unused resolutions |
| JavaScript bundle | 12 MB | 8 MB | moment→dayjs, per-method lodash imports |
| Native libraries | 48 MB | 34 MB | Removing unused Expo modules, ABI splits |
| Localized resources | 6 MB | 4 MB | Deleting unused locales |
| Debug symbols | 22 MB | 2 MB | Separating dSYM from the upload, stripping |
| Total | 172 MB | 69 MB (-60%) | — |
What the table really shows is how unevenly the headroom is distributed. Images gave up 63 MB; localized resources, just 2 MB. Fix the single heaviest component first and the same effort pays off very differently.
On iOS, open Xcode → Organizer → Archives → Download App Size Report to see the post-App-Thinning download size split by cause. On Android, inspect output-metadata.json from ./gradlew :app:bundleRelease, or run APK Analyzer for a per-module breakdown. The goal is to replace "it feels big" with a sentence like "images take up 72MB", because only then can you build a reduction plan.
Re-Encode Assets with the Right Format
In roughly 7 out of 10 bloated Rork apps I've worked on, the largest culprit is image assets. Rork generates high-quality placeholder imagery that fits your prompt, which is convenient — but bundled as PNG, and especially for wallpaper or wellness apps, you'll blow past 100MB in a heartbeat.
Here's the three-step routine I run on every new project:
# 1. Batch-convert to WebP (keep SVG and transparency-critical PNGs untouched)
# brew install webp to get cwebp
find assets/images -type f \( -name "*.png" -o -name "*.jpg" \) -print0 \
| xargs -0 -I {} cwebp -q 82 "{}" -o "{}.webp"
# 2. Compare original vs WebP sizes
du -sh assets/images/**/*.png assets/images/**/*.webp | sort -h | tail -20
# 3. Delete originals and update references in one sweep
find assets/images -type f \( -name "*.png" -o -name "*.jpg" \) -delete
rg -l "\.png\"" src/ | xargs sed -i '' 's/\.png"/\.webp"/g'On the app I was slimming down, image assets dropped from 84MB to 21MB — a 75% cut. React Native supports WebP natively, but flattening transparent PNGs can occasionally break rendering on Android, so keep icon assets that rely on transparency in their original format as a safety net.
If you want to go further on image format choices, I've written a companion piece on the decode cost of WebP vs AVIF and CDN content negotiation.
Make the JavaScript Bundle Explain Itself
If images are handled and the binary is still heavy, the next suspect is a fat dependency that shouldn't be there. Since Expo SDK 55, running npx expo export --dump-sourcemap gives you a sourcemap you can feed into react-native-bundle-visualizer for a per-module treemap.
# Install and analyze
npm install -D react-native-bundle-visualizer
npx react-native-bundle-visualizer --platform ios --dev falseThis usually surfaces the usual suspects: lodash imported wholesale (70KB+), or moment with its timezone data weighing in above 400KB. Swapping lodash for lodash-es with named imports, and moment for date-fns or dayjs, can shave 500KB combined without touching product code.
One Rork-specific gotcha: freshly generated projects sometimes ship with @babel/preset-env in babel.config.js even though Expo's babel-preset-expo already handles everything. Removing the redundant preset alone trims tens of kilobytes from the release bundle.
Tighten the Native Layer with Hermes and ProGuard
On iOS, confirm Hermes is enabled (it's the default in Expo SDK 55 and above). On Android, make sure both enableProguardInReleaseBuilds = true and enableShrinkResourcesInReleaseBuilds = true are set in android/app/build.gradle.
// android/app/build.gradle
android {
buildTypes {
release {
shrinkResources true
minifyEnabled true
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
}
}
}For Android, turning on ABI splits lets you ship an arm64-v8a-only APK, cutting the user-downloaded size roughly in half. If you're using Google Play, simply uploading an App Bundle (AAB) lets Play handle device-specific slicing for you. ABI splits matter most when you're distributing APKs directly, such as through an enterprise channel.
Use App Thinning and On-Demand Resources to Lower Real Download Size
If "total app size" in App Store Connect is still painful after the steps above, the next lever is App Thinning on iOS. Rork tends to emit flat asset files, but if your app — say, a wallpaper app — wants to deliver different resolutions for different devices, register those images in Assets.xcassets with 1x / 2x / 3x slots. The end user will then only download the set matching their device.
On-Demand Resources go one step further: they let you ship a minimal first-boot binary and fetch the rest of your resources later, such as during a tutorial. The payoff is huge for learning and gaming apps, but the integration cost is meaningful. For most Rork apps I'd start with plain App Thinning and reserve ODR for when it clearly pays off.
For the performance angle adjacent to size, see practical techniques for cutting Rork app startup time. Size and startup are a pair — tackle them together and the user-visible improvement compounds.
Measure Binary Size and OTA Update Size as Separate Things
Everything above is about shrinking the binary that ships on first install. Once you're in operations, a second size metric starts to matter: the size of the diff delivered over-the-air.
Expo SDK 55's EAS Update switched to shipping only the Hermes bytecode diff. In my apps, updates that used to re-ship the entire multi-megabyte JS bundle on every feature change now send just the delta, and the update payload dropped by close to 75%. Smaller transfers mean the update applies almost instantly in the background, and your EAS plan's bandwidth budget stretches further.
The catch: slimming the binary does not automatically slim your OTA updates. Bundle a new large image with every release and the diff balloons anyway. Optimize the two separately — the first-install binary with Asset Catalog and App Thinning, and ongoing updates by keeping the JS-and-asset diff small. Expo's fingerprint tool can decide mechanically whether a change is OTA-safe or needs a full native build.
Three Cuts That Came Back to Bite Me
The tricky part about shrinking a binary is that the damage shows up late. The build succeeds, review passes, and the problem only surfaces after release. Here are three I walked into personally.
shrinkResources deletes resources you reference dynamically. If any code builds a resource name as a string and looks it up with getIdentifier(), Gradle sees no reference and strips it. The build is green; the app crashes the moment that screen opens. That's how the category icons in my wallpaper app disappeared. Declare what must survive:
<!-- android/app/src/main/res/raw/keep.xml -->
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@drawable/category_*,@raw/onboarding_*"
tools:shrinkMode="strict" />Adding tools:shrinkMode="strict" makes stripping the default, which sounds aggressive but actually helps: anything you forget to declare fails loudly and early instead of six weeks later.
Dropping dSYMs makes crash logs unreadable. The 22 MB → 2 MB row in the table above comes from here, but deleting the symbol files turns every Crashlytics stack trace into a column of hex addresses. The fix isn't to remove them — it's to separate them from the upload and archive them yourself.
# Pull dSYMs out of the archive, keep them, and strip the shipped binary
cp -R MyApp.xcarchive/dSYMs "$HOME/dsyms/$(date +%Y%m%d)-$(git rev-parse --short HEAD)"
# Upload to Crashlytics from the archived copy
./Pods/FirebaseCrashlytics/upload-symbols -gsp GoogleService-Info.plist -p ios "$HOME/dsyms/..."Naming those folders with the commit hash saved me from the least rewarding investigation in mobile development: figuring out which build a given dSYM belongs to.
Images in the Asset Catalog can't be replaced over the air. Moving images into Assets.xcassets is what makes App Thinning work, but those assets now live on the native side of the fence — EAS Update can't touch them. I moved seasonal artwork in there, then discovered the next swap required a full binary submission.
The line I draw now is simple. Anything fixed for the life of the app — icons, logos, permanent backgrounds — goes in the Asset Catalog. Anything you might swap during operations stays in the bundle, or better yet moves to a CDN. Drawing that line once pays back every release after.
Verify the Win in Delivered Size, Not File Size
One last step before you call it done. Looking at the size of your local .ipa is reassuring but misleading, because users download only the thinned slice for their specific device. A 120 MB artifact on your Mac is routinely 58 MB on the phone.
The Xcode Organizer's App Size Report is authoritative for iOS, but when you want a number before submitting, the export already ships one:
# Read the size report produced by an Ad Hoc / App Store export
cat build/export/App\ Thinning\ Size\ Report.txt | grep -A3 "App size"On Android, generate the real per-device APKs from the bundle and measure those:
# List delivered size across device configurations
bundletool build-apks --bundle=app-release.aab --output=app.apks \
--ks=release.keystore --ks-key-alias=release
bundletool get-size total --apks=app.apks --dimensions=ABI,SDKget-size total reports the minimum and maximum download size per configuration. I save that output before and after each optimization pass. Having the numbers on record turns the next round from guesswork into arithmetic.
Where to Put Tomorrow's Effort
Trying to halve a binary in one pass usually stalls out. The faster path is sequential 20–40% cuts: measure, then images, then the JavaScript bundle, then the native layer. Keeping that order matters because it lets you attribute each gain to a specific change instead of a vague bundle of edits.
If you do exactly one thing tomorrow, open the Xcode Organizer's App Size Report and identify your single heaviest component. If images lead, a WebP pass can move hundreds of megabytes. If the native layer leads, ABI splits and an AAB come first. Working heaviest-first is the whole trick.
A smaller binary does more than silence review warnings. Users on slow connections stop abandoning the install halfway through — and that first download is the first cost anyone pays to try your app. Lowering it is a quiet courtesy to someone you'll never meet.
When the numbers move, write the delta down. It becomes the most reliable estimate you'll have the next time you do this.