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.

FigureBeforeAfterWhat it is
Finder, Get Info on the built app460.1 MB170.4 MBDecimal megabytes. The screenshots below.
Our size report, byte sum of the bundle460.4 M170.3 MDivides by 1024², so MiB.
Compressed archive124.5 MB63.6 MBDownload 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.

Finder Get Info on the app bundle before: 460.1 MB
Finder Get Info on the app bundle after: 170.4 MB

Where the 290 megabytes went

ChangeBefore → afterSavedRunning total
Start460.1
Kotlin Multiplatform framework binary212.4 → 116.8−95.6364.5
Main app binary129.2 → 33.7−95.5269.0
Two notification extensions31.1 → 0.2 each−61.8207.2
Build-only umbrella header17.1 → 0−17.1190.1
Face-detection model files9.6 → 0−9.6180.5
Third-party frameworks and resources, net−8.9171.6
Duplicate fonts−0.9170.7
Places SDK loose resources−0.3170.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

FamilyWhat it isAt baseline
kfun:the code, attributable by package83.6 M
objc2kotlin_kfun: / kotlin2objc_kfun:the Objective-C bridge, one adapter per exported type43.9 M
___unnamed_Nanonymous constants, attributable only by adjacency to code15.4 M
icudt_skiko74_datICU locale data inside Skikoone 6.0 M symbol
kclass: kifacetable: GCC_except_tabclass descriptors, interface tables, exception tables5.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

#LeverMechanismSaved
1Size-report toolingsection 2measurement
2Strip the two big binariesstrip -rSTx on production builds, re-sign−66 M
3Stop shipping the umbrella headercompile-time only; pruned from the embedded copy−16.2 M
4Extensions drop the inherited link lineOTHER_LDFLAGS emptied in six xcconfigs−48.5 M
5Network inspector out of productionflavour-gated dependency, real/stub source sets−0.8 M
6Kotlin/Native size codegensmallBinary on release, latin1Strings on every build type−29.5 M
7Dev tools not exported to productionflavour-gated export() plus #if DEBUG−0.1 M
8Reflection metadata without field namesSWIFT_REFLECTION_METADATA_LEVEL = without-names; Apple documents none as degrading reflectionunmeasured
9Vendor dylibs stripped at sourcestripped in the products directory before embedding−5.4 M
10DTO bridge diet, pilot48 unreferenced DTO types @HiddenFromObjC−0.3 M
11One app icon per flavourASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = NO−1.67 M
12Asset compiler optimises for spaceASSETCATALOG_COMPILER_OPTIMIZATION = spaceunmeasured
13Places SDK removedinert, no consumers−1.5 M
14Shared asset catalogs built for iOS 17, not iOS 9moko-resources defaulted actool to 9.0−480 K
15A lease module off iOSno iOS targets, no export; Android unchanged−1.2 M
16Face-detection SDK removedretired feature; code and six models−27.9 M
17Firebase from CocoaPods to Swift Package Manager, staticdynamic shells become dead-strippable static code−4.3 M
18Fonts served from the shared frameworkthree fonts were byte-identical in app and framework−0.9 M
19Duplicate English strings prunedmoko emits Base.lproj and an identical en.lprojsmall
20Second image loader removedsix call sites migratedin the main-binary line
21CocoaPods removed from the projectPodfile, workspace, phases, includes, CI step, gemone 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 @HiddenFromObjC or internal.
  • 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 doLast is 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 💪.

Related Articles

Understanding and Eliminating Unnecessary SwiftUI View Recompositions

Eliminated long-running view body updates across multiple SwiftUI screens—improving frame stability, reducing CPU load, and preventing dropped frames and UI stutter during interactions and animations.