Journey of Reducing iOS App Size by 63%
14 min read · Written by Ronit Maitra
Every mechanism, number, trap and dead end from taking the Jar iOS bundle from 460 MB to 170 MB: measurement, the size report, 20 levers, Kotlin/Native anatomy, and the two-package-manager crash-report loss.
This is the iOS half of a two-part story. The Android half is Journey of Reducing Android App Size by 33%. This half is for whoever does this next: the mechanisms, the numbers, and the traps, in the order we met them.
Why we did this
Past a size limit, the App Store will not install an app over a mobile connection without asking. Apple's own guidance says so: exceed the limit and users must connect to Wi-Fi, so keep the app well below it to maximise the install base. On iPhone that limit is 200 MB, and with the default setting the prompt appears when the user taps Get. At 460 MB, an install over cellular started with a warning. Many users install over mobile data, and phone storage is finite, so update size and installed size matter too, not only the first download.
Others have measured the effect on growth. Uber published what happened when its iOS app crossed the cellular limit: installs, sign-ups and first bookings all fell. Google's Play data, quoted in the Android half of this story, puts it at roughly one percent of install conversion lost for every 6 MB of size. Size is a growth number that happens to live in the repository. At 170 MB the app installs over cellular without a prompt, updates are smaller, and it takes less of a user's storage.
The result
The Jar iOS app bundle went from 460 MB to 170 MB, measured the same way at both ends. Two figures appear below, so here is what each one is.
| Figure | Before | After | What it is |
|---|---|---|---|
| Finder, Get Info on the built app | 460.1 MB | 170.4 MB | Decimal megabytes. The screenshots below. |
| Our size report, byte sum of the bundle | 460.4 M | 170.3 M | Divides by 1024², so MiB. |
| Compressed archive | 124.5 MB | 63.6 MB | Download proxy only. |
Neither is the install size a user sees. Apple's guidance is explicit: the .app, the .xcarchive and the .ipa are all unsuitable for measuring size, App Store Connect is the most accurate source, and the store may make the final app slightly larger by adding DRM and recompressing. Everything here is in local bundle bytes unless it says otherwise.


Where the 290 megabytes went
| Change | Before → after | Saved | Running total |
|---|---|---|---|
| Start | 460.1 | ||
| Kotlin Multiplatform framework binary | 212.4 → 116.8 | −95.6 | 364.5 |
| Main app binary | 129.2 → 33.7 | −95.5 | 269.0 |
| Two notification extensions | 31.1 → 0.2 each | −61.8 | 207.2 |
| Build-only umbrella header | 17.1 → 0 | −17.1 | 190.1 |
| Face-detection model files | 9.6 → 0 | −9.6 | 180.5 |
| Third-party frameworks and resources, net | −8.9 | 171.6 | |
| Duplicate fonts | −0.9 | 170.7 | |
| Places SDK loose resources | −0.3 | 170.4 |
1. Measurement
Ranked by authority: App Store Connect; the App Thinning Size Report, where Apple defines uncompressed as installed size and compressed as download size; the IPA zip; the byte sum of the .app, which our tooling reads; the .xcarchive, which means nothing. Local bytes are a lower bound on the store figure, not an upper one. A live release read 427.1 MB in Settings against a 407.6 MB thinned local figure from the same release line.
The thinning report costs minutes: re-export an existing archive with all device variants, or pass xcodebuild -exportArchive an options plist with thinning set to the literal <thin-for-all-variants>. On this app thinning saves only 7 to 9 MB, one architecture and universal assets, so the local byte sum stayed a usable proxy.
Three places where a number misled us:
- Units. Our report prints MiB labelled M. Apple's surfaces use decimal MB. Every report figure reads 4.6 percent low against them. Convert first.
- Install versus download. Install fell 46 percent while download fell 34 percent, because what went first, symbol tables, a 16 MB header, duplicated codegen, compresses very well. Quote the install delta, never the download delta.
- The Settings screen. It caches: one install read 236 MB, then 221 MB after force-quitting Settings. App Size and Documents & Data trade bytes as the app runs, total flat. The overhead above the bundle is not a constant either, so the tool reports a range.
2. The instrument
A 460 MB bundle has many owners: two binaries, a shared framework, extensions, asset catalogs, vendor frameworks, resource bundles. Before touching any of them we wanted a number per owner rather than an estimate. So we built a report that assigns every byte in the bundle to an owner, runs as an Xcode post-action after every build and archive, and posts a clickable notification. The list arrived sorted by size, and we worked down from the top.
Attributing bytes
Run nm -n -m. A symbol's size is the address delta to the next symbol in the same segment and section. The attributed total is always less than the file; the remainder, symbol and string tables, padding, fixups, must be shown separately, never folded under a row that cannot be expanded. Stripped binaries attribute from their dSYM: names from the dSYM, sizes from the binary. Swift mangling is length-prefixed and is scanned sequentially. Any catch-all classifier rule runs after every rule that extracts a real name.
Two traps. nm on a stripped binary synthesises 200,000 fake symbols from the export trie in 25 seconds, against a real table of 6,409; read LC_SYMTAB nsyms from otool -l instead. And a dSYM qualifies only on LC_UUID match. Ours chose by symbol count, so a stale dSYM from another flavour won and produced "attributed 163 M of 118 M".
What a Kotlin/Native binary is made of
| Family | What it is | At baseline |
|---|---|---|
kfun: | the code, attributable by package | 83.6 M |
objc2kotlin_kfun: / kotlin2objc_kfun: | the Objective-C bridge, one adapter per exported type | 43.9 M |
___unnamed_N | anonymous constants, attributable only by adjacency to code | 15.4 M |
icudt_skiko74_dat | ICU locale data inside Skiko | one 6.0 M symbol |
kclass: kifacetable: GCC_except_tab | class descriptors, interface tables, exception tables | 5.3 + 3.8 M |
bloaty cannot help here: Kotlin/Native emits one compile unit for the whole framework, so symbol names are the only route.
Post-action mechanics
A post-action needs "Provide build settings from" or it receives nothing. It runs with a scrubbed PATH. A scheme edited on disk while Xcode is open is overwritten. Xcode renames the archive folder as it finishes, so both the build and archive actions run and a run whose input vanished counts as superseded. Never write into TARGET_BUILD_DIR during an archive: one stray folder beside the app made Xcode classify the archive as generic. Non-archive builds keep full symbols, so build-lane reports carry a banner and serve relative comparison only.
Design rules: one Python file, no dependencies beyond the Xcode toolchain; always exit zero so a report can never fail a build; never omit data.
3. The levers
| # | Lever | Mechanism | Saved |
|---|---|---|---|
| 1 | Size-report tooling | section 2 | measurement |
| 2 | Strip the two big binaries | strip -rSTx on production builds, re-sign | −66 M |
| 3 | Stop shipping the umbrella header | compile-time only; pruned from the embedded copy | −16.2 M |
| 4 | Extensions drop the inherited link line | OTHER_LDFLAGS emptied in six xcconfigs | −48.5 M |
| 5 | Network inspector out of production | flavour-gated dependency, real/stub source sets | −0.8 M |
| 6 | Kotlin/Native size codegen | smallBinary on release, latin1Strings on every build type | −29.5 M |
| 7 | Dev tools not exported to production | flavour-gated export() plus #if DEBUG | −0.1 M |
| 8 | Reflection metadata without field names | SWIFT_REFLECTION_METADATA_LEVEL = without-names; Apple documents none as degrading reflection | unmeasured |
| 9 | Vendor dylibs stripped at source | stripped in the products directory before embedding | −5.4 M |
| 10 | DTO bridge diet, pilot | 48 unreferenced DTO types @HiddenFromObjC | −0.3 M |
| 11 | One app icon per flavour | ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO | −1.67 M |
| 12 | Asset compiler optimises for space | ASSETCATALOG_COMPILER_OPTIMIZATION = space | unmeasured |
| 13 | Places SDK removed | inert, no consumers | −1.5 M |
| 14 | Shared asset catalogs built for iOS 17, not iOS 9 | moko-resources defaulted actool to 9.0 | −480 K |
| 15 | A lease module off iOS | no iOS targets, no export; Android unchanged | −1.2 M |
| 16 | Face-detection SDK removed | retired feature; code and six models | −27.9 M |
| 17 | Firebase from CocoaPods to Swift Package Manager, static | dynamic shells become dead-strippable static code | −4.3 M |
| 18 | Fonts served from the shared framework | three fonts were byte-identical in app and framework | −0.9 M |
| 19 | Duplicate English strings pruned | moko emits Base.lproj and an identical en.lproj | small |
| 20 | Second image loader removed | six call sites migrated | in the main-binary line |
| 21 | CocoaPods removed from the project | Podfile, workspace, phases, includes, CI step, gem | one fewer toolchain |
4. What each lever taught
Stripping
The raw shared framework was 197.3 M: sections 116.5 M, symbol table 26.7 M with 1.75 million entries, string table 54.0 M. strip -rSTx on a copy gave 118.1 M against the ledger's 118.2. Sections ship byte-identical, and the names live in the dSYM the crash reporter reads.
The extensions were real code. Each xcconfig included the app's flavour file, so the app's whole vendor link line reached them. What they need, UserNotifications and one small library, arrives through Swift autolink and survives an empty link line.
Kotlin/Native codegen
smallBinary sets -Oz for the LLVM phase. With latin1Strings it took the framework's code from 99.5 to 40.2 M while the bridge grew 27.3 to 32.8 M and a 3.6 M "runtime stubs" bucket appeared: outlining moving code into thunks, not a larger API. latin1Strings is on for every build type so staging exercises what production ships; 97.5 percent of our string literals fit Latin-1.
Closed after measuring: -Wl,-x (our strip already leaves nlocalsym = 1), isStatic (doubled one community framework), bitcode (gone since Xcode 14), fat-framework splitting (the store does it). The Compose Multiplatform stack measures 19.8 M, and JetBrains' replies on the tracking issues do not expect it to shrink significantly; that is the floor while Compose stays. LTO is untried.
Export trimming
The plan was to grep Swift per shared module and drop export() entries with zero hits. Of 51 exported modules, none had zero iOS usage: 1,687 Swift files, 31,429 identifiers, intersected against every public type. The one apparent zero-hit was our own regex missing a module of top-level functions.
The bridge cost is the price of the exported API surface, not of the module list. It shrinks type by type with internal or @HiddenFromObjC. Over half the 32.8 M sits on domain.model and data.model packages, generated adapters for DTO data classes. The pilot hid 48 types and broke on types reached through Swift type inference. The filter that holds: zero Swift hits, referenced by no other Kotlin file, no public top-level function in the file, not inherited by an exported class. 591 types pass it. Member-level hiding has no inference gap, because Swift member access is always nominal; that pool is 4.99 M across 13,810 properties.
Assets
moko-resources 0.26.4 passes a 9.0 minimum deployment target to actool. actool emits a rendition per compression codec, stamped with the OS that reads it, so iOS 17 read one variant and shipped three. Hand-running actool on moko's generated catalogs: 977 K to 498 K, all names preserved. The generated catalogs survive in each module's build directory, so actool flags can be A/B-tested without Gradle.
Then the setting did nothing. moko packs the car in a doLast and never declares the target as a task input, so only two of ten modules regenerated. A Gradle setting that feeds only a doLast is not wired to anything; declare it as an input. The same build also dropped 8.6 M on a 17 K change: 5.6 M was the app's catalog thinned to one screen scale by a device-targeted build, 3.0 M was the flavour flip below. Diff two report bodies before crediting a delta.
Source-side: fourteen imagesets ship byte-identical files at two or three scales, 1.68 MB. A scan for unreferenced assets reported 87 entries; this codebase references assets through Xcode's generated symbols, plus_sign as .plusSign, and matching those too left 7 entries, 0.17 MB.
Localisation
du over 246 small lproj directories reported 2.26 M against a real 1.53 M; sum file bytes. moko emits Base and an identical en for every module; Base is what iOS falls back to, so en never answers a lookup. Pruned with a diff guard. Vendor locale bundles follow the device, so pruning them is a product decision; we built it, measured 5.3 K, discarded it. The dry run caught one bug in our own script, a fail-safe placed after the value it guards. Assert on the raw input, never on a value already mixed with defaults.
Dependencies
The second image loader: about 70 files imported it, six used it. An import count is not a usage count. The Places SDK: predicted 0.57 M, measured 1.5 M, because it prefixes transitive dependencies with GMPx_ and the report had no rule for that. The face-detection SDK: predicted 18.6 M, measured 27.9 M, same mechanism with MLKITx_. Size a static dependency by the whole-binary delta across two builds, not by the report row bearing its name. A large unclassified bucket is unattributed vendor code.
Taking a shared module off iOS without touching Android: a platform check in settings.gradle.kts cannot work, since settings run before any target exists and typesafe accessors compile into every script. What works: the module drops its iOS targets, its export() goes, and its api() moves from commonMain to an androidMain block. export() disables dead-code elimination for the whole module, so all 1.2 M had shipped.
The build that changed between runs
The shared framework's build script gates a few decisions on the current flavour, read from a Kotlin object singleton in buildSrc. The singleton loads gradle.properties, where the committed value is staging; the command-line override lands inside gradle.projectsEvaluated, after every module script has run. The singleton also lives as long as the Gradle daemon, so a warm daemon read the previous build's flavour and a cold one read the file. Two production builds forty minutes apart, no code change, differed by 3.04 M. The fix is one identifier:
val flavour = (findProperty(KEY_FLAVOUR) ?: FLAVOUR_STAGING).toString()Our new gates use it. The older ones wait for their own ticket, since changing what production links deserves its own QA. When comparing two bundle totals here, check that the flavour-gated module is absent in both and the asset catalog is the same size in both; a device-targeted build thins it by 5.2 M.
Firebase from CocoaPods to Swift Package Manager
Under CocoaPods each Firebase module arrived as a dynamic framework: a 51 KB shell with no exported symbols wrapping a static payload linked into the app. The linker cannot trim code inside a file it only meets at launch. As Swift packages the same modules are static libraries; the linker sees every function and drops what nothing calls. Net −4.3 M with -ObjC, which costs 0.80 M and is required for Analytics' categories. CocoaPods had been injecting that flag through its generated xcconfig all along.
Static versus dynamic, measured: a dynamic aggregator needs the linker flag on the package target itself, and with this project's wiring Xcode did not embed it, so the app aborted at launch. The smallest report we ever saw, 158 M, was that build. A size report is only valid for an app that launches. Embedded, dynamic was 0.4 M larger and three seconds faster per incremental build. We chose static; a project with several executables sharing the framework could reasonably choose otherwise.
Proving Crashlytics still worked meant reading the SDK's on-device store, not the dashboard: handlers install only with no debugger attached, and uploads happen on the next launch. A one-variable-per-build bisection found no code change that gated visibility.
The crash reports that never left the device
We started from a belief that Swift Package Manager had broken Crashlytics, from four releases earlier this year that shipped Firebase via SPM and appeared to lose crash reports until a hotfix moved Firebase back to pods. The bisection cleared every candidate in the repository. The cause was in how the app was assembled.
Those releases shipped Firebase through SPM while a face-detection pod still delivered Google's support libraries, GoogleUtilities, GoogleDataTransport, Promises, GTMSessionFetcher, as dynamic frameworks. The app carried two copies of GoogleDataTransport, one static in the binary, one dylib. The runtime says so at launch, 149 times: "Class X is implemented in both … One of the duplicates must be removed or renamed", covering the whole Crashlytics upload path. Each copy has its own storage, coordinator and uploader on the same on-disk folder. Two coordinators over one directory can create a batch and have the other copy clean it up before any request goes out, while Crashlytics is told the write succeeded.
Reproduced at the desk on one of those releases: three deliberate crashes, one uploaded with an HTTP 200, two gone after "Completed report submission" with no request, no stored event, no batch. Two controls: later releases with everything on CocoaPods, and this branch with every pod removed, have not shown the loss. Crashlytics works from pods alone or from SPM alone. The Google stack comes from exactly one package manager. Check with otool -ov class lists on the app and each embedded framework, and the console filtered on "implemented in both".
Two smaller ones
The vendor-strip glob matched the products root only, so 23 pod frameworks in per-pod subdirectories were never stripped. And gating the dev-tools export failed the production archive because the Swift file defining the screens had no guard of its own. Check the definition site, not only the call sites.
5. Git, pbxproj and the submodule
With submodule.recurse = true, a superproject reset wipes uncommitted work inside the submodule and git push fails when branch names differ; push each repo separately with --no-recurse-submodules. Xcode re-serialises the project file at will, so judge a pbxproj change by object diff, then ask whether pod install produced it: it correctly deletes the copy-resources phase when the last pod with resources leaves. Rebuild the file from HEAD and re-apply only the intended edits. Lift work from other branches with a real git cherry-pick.
6. Keeping it small
Adding a dependency
- Build without it, build with it. The whole-binary delta is its cost.
- Google libraries come from one package manager, the one the rest of the Google stack uses.
- Static or dynamic: measure both. Static won here; a different project shape may decide differently.
- Check whether an existing dependency already does the job.
Adding a shared Kotlin type or module
- Every exported public type costs bridge bytes. Types Swift never names get
@HiddenFromObjCorinternal. - A module iOS does not use declares no iOS targets and is not in
export(). - Flavour gates read
findProperty, not a singleton. - A setting that only feeds a
doLastis declared as a task input.
Adding an asset
- One file per scale, checked with md5.
- Photos as JPEG or HEIC.
- Nothing the shared framework already ships.
- Unreferenced-asset scans match the generated symbol too.
Each release
- Read the archive report, not a device-targeted build.
- Confirm the flavour-gated module is absent and the catalog is universal.
- Quote thinned install size in decimal MB.
- The report's warning line and ceiling are the budget.
Trusting a number
- Two independent methods before writing it down.
- dSYM by UUID. Report only from an app that launches. No two-point calibrations.
- Diff two report bodies before crediting a delta.
7. Conclusion
The bundle went from 460 MB to 170 MB, the download from 124 MB to 64 MB. Most of the weight was never anyone's feature code: symbol tables the phone never reads, a header only the compiler needed, extensions inheriting the app's link line, an SDK behind a retired feature, the same fonts twice. None of it is visible from the source tree, which is why a per-byte report was the first step.
Two things outlast the number. The report runs on every build and archive, with a warning line and a ceiling, so size is read rather than remembered. And the list in section 6 is short enough to follow without this post. Here, size lived in the build system and the dependency list more than in feature code, so it is easiest to keep in check at the moment a dependency, module or asset is added.
And the app is CocoaPods-free. Zero pods, one package manager, and the whole Google stack from a single source. The Podfile, the workspace, the manifest check phase, the three xcconfig includes, the CI install step and the gem are gone; the project opens straight from .xcodeproj, packages resolve on the first build, and Crashlytics was verified on device after the switch. Swift Package Manager is Apple's own dependency manager, and the CocoaPods maintainers have announced their trunk goes read-only on 2 December 2026. We finished the move well ahead of that date, with the size win to show for it 💪.